From cb251f78ad8470627d68bd4c18d97c705ddc83d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:19:22 +0900 Subject: [PATCH 001/448] test(audio): define canonical resource policy contract --- .../tests/test_audio_resource_policy.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_resource_policy.py diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py new file mode 100644 index 000000000..ca48b0980 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -0,0 +1,83 @@ +"""Tests for the canonical local-audio resource policy.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import ( + AUDIO_RESOURCE_POLICY_VERSION, + AudioResourcePolicy, + DEFAULT_AUDIO_RESOURCE_POLICY, +) + + +def test_default_policy_has_stable_version_and_rehearsal_budget() -> None: + """The default policy exposes one versioned budget shared by analyzers.""" + assert AUDIO_RESOURCE_POLICY_VERSION == "1" + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes == 100 * 1024 * 1024 + assert DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate == 44_100 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_duration_seconds == 15 * 60 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_samples == 44_100 * 15 * 60 + + +@pytest.mark.parametrize("file_size", [True, -1, 0, 101]) +def test_encoded_file_size_fails_closed_outside_policy(file_size: object) -> None: + """Invalid, empty, or oversized encoded inputs are rejected before decode.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_encoded_file_bytes(file_size) + + +def test_encoded_file_size_accepts_exact_boundary() -> None: + """A non-empty encoded file exactly at the configured ceiling is accepted.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + assert policy.validate_encoded_file_bytes(100) == 100 + + +@pytest.mark.parametrize( + ("audio", "sample_rate"), + [ + (np.zeros(8_001, dtype=np.float32), 8_000), + (np.zeros((2, 4_000), dtype=np.float32), 8_000), + (np.array([0.0, np.nan], dtype=np.float32), 8_000), + (np.zeros(10, dtype=np.float32), 0), + (np.zeros(10, dtype=np.float32), True), + ], +) +def test_decoded_audio_fails_closed_outside_policy( + audio: np.ndarray, + sample_rate: object, +) -> None: + """Decoded output is revalidated for shape, finiteness, rate, and sample budget.""" + policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_decoded_audio(audio, sample_rate) + + +def test_decoded_audio_accepts_exact_sample_boundary() -> None: + """A finite mono artifact exactly at the decoded-sample ceiling is accepted.""" + policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) + audio = np.zeros(8_000, dtype=np.float32) + + validated = policy.validate_decoded_audio(audio, 8_000) + + assert validated is audio + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_encoded_file_bytes": 0}, + {"target_sample_rate": 0}, + {"max_duration_seconds": 0.0}, + {"max_duration_seconds": float("inf")}, + ], +) +def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> None: + """Invalid policy construction cannot silently create an unbounded budget.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(**kwargs) # type: ignore[arg-type] From 3e96ed68df37a300739308400fd1dedec1cd24bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:20:01 +0900 Subject: [PATCH 002/448] feat(audio): add canonical resource policy --- .../audio_resource_policy.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py new file mode 100644 index 000000000..4243092bf --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -0,0 +1,139 @@ +"""Canonical resource admission policy for local audio analysis. + +The policy is intentionally independent of individual analyzers. Expensive +feature code consumes a decoded artifact only after encoded-file and decoded +output checks agree on the same versioned limits. This prevents temporal, +separation, chord, and register features from silently inventing incompatible +resource ceilings. + +Security Notes: +- Encoded byte counts are validated before decode/allocation work when the + opened file descriptor can provide an authoritative size. +- Decoded audio is revalidated because container metadata and decoder behavior + are untrusted; accepted artifacts are finite, mono, at the configured sample + rate, and within the configured decoded-sample budget. +- Validation errors are payload-free and never include source paths or audio + content. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np +from numpy.typing import NDArray + +AUDIO_RESOURCE_POLICY_VERSION = "1" +DEFAULT_TARGET_SAMPLE_RATE = 44_100 +DEFAULT_MAX_ENCODED_FILE_BYTES = 100 * 1024 * 1024 +DEFAULT_MAX_DURATION_SECONDS = 15 * 60 +_POLICY_ERROR = "Audio input violates the audio resource policy." + + +@dataclass(frozen=True) +class AudioResourcePolicy: + """Versioned limits applied before and after local audio decoding. + + Args: + max_encoded_file_bytes: Maximum non-empty encoded source size. + target_sample_rate: Required sample rate of the canonical decoded mono + artifact. + max_duration_seconds: Maximum decoded duration represented as a sample + ceiling at ``target_sample_rate``. + """ + + max_encoded_file_bytes: int = DEFAULT_MAX_ENCODED_FILE_BYTES + target_sample_rate: int = DEFAULT_TARGET_SAMPLE_RATE + max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) + + def __post_init__(self) -> None: + """Reject invalid policy configuration before it can weaken admission.""" + if ( + isinstance(self.max_encoded_file_bytes, bool) + or not isinstance(self.max_encoded_file_bytes, int) + or self.max_encoded_file_bytes <= 0 + ): + raise ValueError(_POLICY_ERROR) + if ( + isinstance(self.target_sample_rate, bool) + or not isinstance(self.target_sample_rate, int) + or self.target_sample_rate <= 0 + ): + raise ValueError(_POLICY_ERROR) + if ( + isinstance(self.max_duration_seconds, bool) + or not isinstance(self.max_duration_seconds, int | float) + or not math.isfinite(float(self.max_duration_seconds)) + or float(self.max_duration_seconds) <= 0.0 + ): + raise ValueError(_POLICY_ERROR) + + @property + def max_decoded_samples(self) -> int: + """Return the maximum mono sample count allowed after decoding.""" + return int(self.target_sample_rate * float(self.max_duration_seconds)) + + def validate_encoded_file_bytes(self, file_size: object) -> int: + """Validate an authoritative encoded file size before decoding. + + Args: + file_size: Byte count obtained from the already-open source file. + + Returns: + The validated integer byte count. + + Raises: + ValueError: If the value is not a positive integer within policy. + """ + if ( + isinstance(file_size, bool) + or not isinstance(file_size, int) + or file_size <= 0 + or file_size > self.max_encoded_file_bytes + ): + raise ValueError(_POLICY_ERROR) + return file_size + + def validate_decoded_audio( + self, + audio: object, + sample_rate: object, + ) -> NDArray[np.floating[Any]]: + """Revalidate the canonical decoded artifact before feature analysis. + + Args: + audio: Candidate mono NumPy array returned by the decoder. + sample_rate: Decoder-reported sample rate in Hz. + + Returns: + The original validated NumPy array without copying it. + + Raises: + ValueError: If shape, sample rate, sample count, or finiteness does + not satisfy this policy. + """ + if not isinstance(audio, np.ndarray) or audio.ndim != 1 or audio.size == 0: + raise ValueError(_POLICY_ERROR) + if ( + isinstance(sample_rate, bool) + or not isinstance(sample_rate, int) + or sample_rate != self.target_sample_rate + ): + raise ValueError(_POLICY_ERROR) + if audio.size > self.max_decoded_samples or not np.isfinite(audio).all(): + raise ValueError(_POLICY_ERROR) + return cast(NDArray[np.floating[Any]], audio) + + +DEFAULT_AUDIO_RESOURCE_POLICY = AudioResourcePolicy() + +__all__ = [ + "AUDIO_RESOURCE_POLICY_VERSION", + "AudioResourcePolicy", + "DEFAULT_AUDIO_RESOURCE_POLICY", + "DEFAULT_MAX_DURATION_SECONDS", + "DEFAULT_MAX_ENCODED_FILE_BYTES", + "DEFAULT_TARGET_SAMPLE_RATE", +] From f83a1baebc793658c4d1805be00f11238f09ceac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:35:29 +0900 Subject: [PATCH 003/448] test(score): require bounded validated PDF reads --- apps/desktop/core/tests/score_pdf_read.rs | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_read.rs diff --git a/apps/desktop/core/tests/score_pdf_read.rs b/apps/desktop/core/tests/score_pdf_read.rs new file mode 100644 index 000000000..4be751f32 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_read.rs @@ -0,0 +1,78 @@ +use bandscope_desktop_core::{read_validated_score_pdf, MAX_SCORE_PDF_BYTES}; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +#[test] +fn score_pdf_read_returns_only_valid_bounded_pdf_bytes() { + let root = unique_test_dir("score-read-valid"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("score.pdf"); + let expected = b"%PDF-1.7\nvalidated body"; + std::fs::write(&path, expected).expect("valid PDF fixture should be written"); + + let actual = read_validated_score_pdf(&path).expect("valid stored PDF should be readable"); + + assert_eq!(actual, expected); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_empty_short_and_wrong_magic_content() { + let root = unique_test_dir("score-read-invalid"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + + for (name, content) in [ + ("empty.pdf", b"".as_slice()), + ("short.pdf", b"%PD".as_slice()), + ("wrong.pdf", b"PK\x03\x04 not a PDF".as_slice()), + ] { + let path = root.join(name); + std::fs::write(&path, content).expect("invalid PDF fixture should be written"); + let error = read_validated_score_pdf(&path).expect_err("invalid PDF must fail closed"); + assert!( + error == "Could not read the score PDF." || error == "Stored score is not a valid PDF.", + "unexpected payload-safe error: {error}" + ); + assert!(!error.contains(root.to_string_lossy().as_ref())); + } + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_oversized_sparse_file_after_bounded_read() { + let root = unique_test_dir("score-read-oversized"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("oversized.pdf"); + let mut file = std::fs::File::create(&path).expect("oversized PDF fixture should be created"); + file.write_all(b"%PDF-") + .expect("PDF magic should be written before extending sparse file"); + file.set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("sparse PDF fixture should exceed the product limit"); + drop(file); + + let error = read_validated_score_pdf(&path).expect_err("oversized PDF must fail closed"); + + assert_eq!(error, "Score PDF is too large (exceeds 25MB limit)."); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_pdf_read_rejects_missing_file_without_exposing_path() { + let root = unique_test_dir("score-read-missing"); + let path = root.join("private-score.pdf"); + + let error = read_validated_score_pdf(&path).expect_err("missing PDF must fail closed"); + + assert_eq!(error, "Could not read the score PDF."); + assert!(!error.contains("private-score.pdf")); +} From d659d9ddd1fb9c9c5c9a97b2ab92c97376d21508 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:47:13 +0900 Subject: [PATCH 004/448] refactor(core): expose bounded score reader module --- apps/desktop/core/Cargo.toml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index b01a537dc..5142b0144 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -2,21 +2,24 @@ name = "bandscope-desktop-core" version = "0.1.0" edition = "2021" -description = "GUI-independent payload contracts and validation logic for the BandScope desktop app." -publish = false [lib] -name = "bandscope_desktop_core" -path = "src/lib.rs" +path = "src/root.rs" -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } +[features] +custom-protocol = [] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -time = { version = "0.3", features = ["formatting", "macros"] } -url = "2.5.8" +time = { version = "0.3.53", features = ["formatting"] } +url = "2" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage)"] } -[dev-dependencies] -uuid = { version = "1", features = ["v4"] } +[lints.clippy] +# These lints are command-/behavior-level refactors. The current desktop core +# is being held byte-for-byte on behavior while coverage closure lands. +too_many_arguments = "allow" +type_complexity = "allow" From fc1af87708d63221daddc55e7f75eaf39b4dd7f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:47:20 +0900 Subject: [PATCH 005/448] refactor(core): preserve public API through root module --- apps/desktop/core/src/root.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/core/src/root.rs diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs new file mode 100644 index 000000000..6dd1f4fc5 --- /dev/null +++ b/apps/desktop/core/src/root.rs @@ -0,0 +1,13 @@ +//! Pure, GUI-independent logic for the BandScope desktop application. +//! +//! The historical desktop-core implementation remains in `lib.rs` as the +//! compatibility module while bounded score-file I/O is isolated in its own +//! auditable module. Public symbols are re-exported so downstream callers keep +//! the same crate-root API. + +#[path = "lib.rs"] +mod runtime_core; +mod score_pdf; + +pub use runtime_core::*; +pub use score_pdf::read_validated_score_pdf; From 1df5fe0d6a0ada62762fee91c569a1c9b87b49c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:47:32 +0900 Subject: [PATCH 006/448] fix(score): bound stored PDF reads before allocation --- apps/desktop/core/src/score_pdf.rs | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/core/src/score_pdf.rs diff --git a/apps/desktop/core/src/score_pdf.rs b/apps/desktop/core/src/score_pdf.rs new file mode 100644 index 000000000..75e744162 --- /dev/null +++ b/apps/desktop/core/src/score_pdf.rs @@ -0,0 +1,48 @@ +use crate::{MAX_SCORE_PDF_BYTES, PDF_MAGIC}; +use std::{fs::File, io::Read, path::Path}; + +const SCORE_READ_ERROR: &str = "Could not read the score PDF."; +const SCORE_TOO_LARGE_ERROR: &str = "Score PDF is too large (exceeds 25MB limit)."; +const SCORE_INVALID_PDF_ERROR: &str = "Stored score is not a valid PDF."; + +/// Read one already-authorized stored score without allocating beyond the PDF limit. +/// +/// The caller remains responsible for path authority and containment. This helper +/// opens that resolved path once, snapshots the descriptor length, allocates only +/// that bounded size, reads exactly that many bytes, and then probes one additional +/// byte on the same descriptor. A file that was already oversized is rejected +/// before heap allocation; a file that grows after metadata inspection is rejected +/// by the one-byte probe without extending the heap buffer beyond the product cap. +/// Errors intentionally omit the local path and file content. +pub fn read_validated_score_pdf(path: &Path) -> Result, String> { + let mut file = File::open(path).map_err(|_| SCORE_READ_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| SCORE_READ_ERROR.to_string())?; + if !metadata.is_file() { + return Err(SCORE_READ_ERROR.to_string()); + } + if metadata.len() > MAX_SCORE_PDF_BYTES { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + let expected_len = usize::try_from(metadata.len()).map_err(|_| SCORE_TOO_LARGE_ERROR.to_string())?; + let mut bytes = vec![0_u8; expected_len]; + file.read_exact(&mut bytes) + .map_err(|_| SCORE_READ_ERROR.to_string())?; + + let mut growth_probe = [0_u8; 1]; + if file + .read(&mut growth_probe) + .map_err(|_| SCORE_READ_ERROR.to_string())? + != 0 + { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + if !bytes.starts_with(PDF_MAGIC) { + return Err(SCORE_INVALID_PDF_ERROR.to_string()); + } + + Ok(bytes) +} From 521fe127056f98141ebb0e5ee878e72092441acc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:48:52 +0900 Subject: [PATCH 007/448] fix(score): use bounded native PDF reader --- apps/desktop/src-tauri/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..94b6c1a61 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -826,7 +826,9 @@ fn attach_score_pdf( /// Security Notes: no path crosses the IPC boundary. Both ids are validated /// against strict allowlist shapes, the path is rebuilt locally, and the /// canonicalize-plus-prefix guard in `resolve_existing_score_pdf` rejects any -/// escape from the app-owned scores root. +/// escape from the app-owned scores root. The resolved file is then read +/// through the bounded core helper so growth after attachment cannot trigger +/// an allocation beyond the 25 MiB product limit. #[tauri::command] fn read_score_pdf( project_id: String, @@ -838,7 +840,7 @@ fn read_score_pdf( } let scores_root = scores_root_for_project(&app, &project_id)?; let path = resolve_existing_score_pdf(&scores_root, &score_id)?; - std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) + read_validated_score_pdf(&path) } /// Security Notes: same id validation and traversal guard as `read_score_pdf`; From ca20dc5ce87d14a245398bdce3ef74852bba9188 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:49:13 +0900 Subject: [PATCH 008/448] style(score): keep bounded reader rustfmt-clean --- apps/desktop/core/src/score_pdf.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/score_pdf.rs b/apps/desktop/core/src/score_pdf.rs index 75e744162..7d7275aef 100644 --- a/apps/desktop/core/src/score_pdf.rs +++ b/apps/desktop/core/src/score_pdf.rs @@ -26,7 +26,8 @@ pub fn read_validated_score_pdf(path: &Path) -> Result, String> { return Err(SCORE_TOO_LARGE_ERROR.to_string()); } - let expected_len = usize::try_from(metadata.len()).map_err(|_| SCORE_TOO_LARGE_ERROR.to_string())?; + let expected_len = usize::try_from(metadata.len()) + .map_err(|_| SCORE_TOO_LARGE_ERROR.to_string())?; let mut bytes = vec![0_u8; expected_len]; file.read_exact(&mut bytes) .map_err(|_| SCORE_READ_ERROR.to_string())?; From e6d31ee0eabc8a678354c5892b24d72c366b4c5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:49:32 +0900 Subject: [PATCH 009/448] docs(changelog): record bounded stored-score reads --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..fdfea855d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. + ## [0.1.3] - 2026-04-29 ### Fixed From 051e39d7d332b45267fd947483b243bb09b7dfb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:50:19 +0900 Subject: [PATCH 010/448] fix(core): preserve desktop-core package contract --- apps/desktop/core/Cargo.toml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index 5142b0144..44f482e73 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -2,24 +2,21 @@ name = "bandscope-desktop-core" version = "0.1.0" edition = "2021" +description = "GUI-independent payload contracts and validation logic for the BandScope desktop app." +publish = false [lib] +name = "bandscope_desktop_core" path = "src/root.rs" -[features] -custom-protocol = [] +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -time = { version = "0.3.53", features = ["formatting"] } -url = "2" - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage)"] } +time = { version = "0.3", features = ["formatting", "macros"] } +url = "2.5.8" -[lints.clippy] -# These lints are command-/behavior-level refactors. The current desktop core -# is being held byte-for-byte on behavior while coverage closure lands. -too_many_arguments = "allow" -type_complexity = "allow" +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } From c11af77b6f199cf4e04280a36311874fdd7ab599 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:51:51 +0900 Subject: [PATCH 011/448] test(score): cover non-file stored score reads --- apps/desktop/core/tests/score_pdf_read.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/score_pdf_read.rs b/apps/desktop/core/tests/score_pdf_read.rs index 4be751f32..b70068931 100644 --- a/apps/desktop/core/tests/score_pdf_read.rs +++ b/apps/desktop/core/tests/score_pdf_read.rs @@ -49,7 +49,7 @@ fn score_pdf_read_rejects_empty_short_and_wrong_magic_content() { } #[test] -fn score_pdf_read_rejects_oversized_sparse_file_after_bounded_read() { +fn score_pdf_read_rejects_oversized_sparse_file_before_heap_allocation() { let root = unique_test_dir("score-read-oversized"); std::fs::create_dir_all(&root).expect("test directory should be created"); let path = root.join("oversized.pdf"); @@ -66,6 +66,19 @@ fn score_pdf_read_rejects_oversized_sparse_file_after_bounded_read() { let _ = std::fs::remove_dir_all(root); } +#[cfg(unix)] +#[test] +fn score_pdf_read_rejects_non_file_descriptor() { + let root = unique_test_dir("score-read-directory"); + std::fs::create_dir_all(&root).expect("test directory should be created"); + + let error = read_validated_score_pdf(&root).expect_err("directory must fail closed"); + + assert_eq!(error, "Could not read the score PDF."); + assert!(!error.contains(root.to_string_lossy().as_ref())); + let _ = std::fs::remove_dir_all(root); +} + #[test] fn score_pdf_read_rejects_missing_file_without_exposing_path() { let root = unique_test_dir("score-read-missing"); From f86e266b2ab2dc5a95e6b4a484e777b29f0feeaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:52:09 +0900 Subject: [PATCH 012/448] test(score): prove same-descriptor growth detection --- apps/desktop/core/src/score_pdf.rs | 71 ++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/apps/desktop/core/src/score_pdf.rs b/apps/desktop/core/src/score_pdf.rs index 7d7275aef..2b26744cc 100644 --- a/apps/desktop/core/src/score_pdf.rs +++ b/apps/desktop/core/src/score_pdf.rs @@ -5,6 +5,36 @@ const SCORE_READ_ERROR: &str = "Could not read the score PDF."; const SCORE_TOO_LARGE_ERROR: &str = "Score PDF is too large (exceeds 25MB limit)."; const SCORE_INVALID_PDF_ERROR: &str = "Stored score is not a valid PDF."; +fn read_validated_pdf_stream( + reader: &mut impl Read, + expected_len: u64, +) -> Result, String> { + if expected_len > MAX_SCORE_PDF_BYTES { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + // MAX_SCORE_PDF_BYTES is 25 MiB, which fits every supported Rust `usize`. + let mut bytes = vec![0_u8; expected_len as usize]; + reader + .read_exact(&mut bytes) + .map_err(|_| SCORE_READ_ERROR.to_string())?; + + let mut growth_probe = [0_u8; 1]; + if reader + .read(&mut growth_probe) + .map_err(|_| SCORE_READ_ERROR.to_string())? + != 0 + { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + if !bytes.starts_with(PDF_MAGIC) { + return Err(SCORE_INVALID_PDF_ERROR.to_string()); + } + + Ok(bytes) +} + /// Read one already-authorized stored score without allocating beyond the PDF limit. /// /// The caller remains responsible for path authority and containment. This helper @@ -22,28 +52,31 @@ pub fn read_validated_score_pdf(path: &Path) -> Result, String> { if !metadata.is_file() { return Err(SCORE_READ_ERROR.to_string()); } - if metadata.len() > MAX_SCORE_PDF_BYTES { - return Err(SCORE_TOO_LARGE_ERROR.to_string()); - } + read_validated_pdf_stream(&mut file, metadata.len()) +} - let expected_len = usize::try_from(metadata.len()) - .map_err(|_| SCORE_TOO_LARGE_ERROR.to_string())?; - let mut bytes = vec![0_u8; expected_len]; - file.read_exact(&mut bytes) - .map_err(|_| SCORE_READ_ERROR.to_string())?; +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; - let mut growth_probe = [0_u8; 1]; - if file - .read(&mut growth_probe) - .map_err(|_| SCORE_READ_ERROR.to_string())? - != 0 - { - return Err(SCORE_TOO_LARGE_ERROR.to_string()); - } + #[test] + fn stream_rejects_growth_after_the_metadata_length_snapshot() { + let mut reader = Cursor::new(b"%PDF-extra".to_vec()); - if !bytes.starts_with(PDF_MAGIC) { - return Err(SCORE_INVALID_PDF_ERROR.to_string()); + let error = read_validated_pdf_stream(&mut reader, PDF_MAGIC.len() as u64) + .expect_err("bytes beyond the metadata snapshot must fail closed"); + + assert_eq!(error, SCORE_TOO_LARGE_ERROR); } - Ok(bytes) + #[test] + fn stream_rejects_truncation_after_the_metadata_length_snapshot() { + let mut reader = Cursor::new(PDF_MAGIC.to_vec()); + + let error = read_validated_pdf_stream(&mut reader, (PDF_MAGIC.len() + 1) as u64) + .expect_err("truncation after the metadata snapshot must fail closed"); + + assert_eq!(error, SCORE_READ_ERROR); + } } From 6aa00980ada2630e8ce19f434c5ac3fdc68fb3a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:17:44 +0900 Subject: [PATCH 013/448] test(audio): require policy parity at orchestration and decode boundaries --- .../test_audio_resource_policy_integration.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_resource_policy_integration.py diff --git a/services/analysis-engine/tests/test_audio_resource_policy_integration.py b/services/analysis-engine/tests/test_audio_resource_policy_integration.py new file mode 100644 index 000000000..36f9c851a --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy_integration.py @@ -0,0 +1,156 @@ +"""Cross-boundary regressions for canonical local-audio resource admission.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.api import validate_analysis_job_request +from bandscope_analysis.audio_resource_policy import ( + AudioResourcePolicy, + DEFAULT_AUDIO_RESOURCE_POLICY, +) +from bandscope_analysis.separation.audio_separator import ( + AudioSeparationConfig, + AudioStemSeparator, +) +from bandscope_analysis.temporal.analyzer import TemporalAnalyzer + + +def _local_request(file_size_bytes: object) -> dict[str, object]: + """Build one local-audio request whose only variable is encoded byte metadata.""" + return { + "sourceKind": "local_audio", + "projectId": "policy-project", + "sourceLabel": "rehearsal.wav", + "roleFocus": [], + "localSource": { + "sourcePath": "/tmp/rehearsal.wav", + "fileName": "rehearsal.wav", + "extension": "wav", + "fileSizeBytes": file_size_bytes, + }, + } + + +@pytest.mark.parametrize( + "file_size_bytes", + [True, DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + 1], +) +def test_request_preflight_rejects_metadata_outside_canonical_policy( + file_size_bytes: object, +) -> None: + """Reject impossible/oversized metadata before orchestration starts expensive work.""" + with pytest.raises(ValueError, match="localSource.fileSizeBytes"): + validate_analysis_job_request(_local_request(file_size_bytes)) + + +def test_request_preflight_accepts_exact_encoded_byte_boundary() -> None: + """The service API accepts the same exact encoded-byte ceiling as the policy.""" + request = validate_analysis_job_request( + _local_request(DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes) + ) + + assert request["localSource"]["fileSizeBytes"] == DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + + +def test_temporal_decoder_probes_one_sample_past_duration_limit_and_fails_closed( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Temporal decode detects a one-sample-overlong source instead of silently truncating it.""" + import librosa + + policy = AudioResourcePolicy( + max_encoded_file_bytes=100, + target_sample_rate=8, + max_duration_seconds=1.0, + ) + source = tmp_path / "overlong.wav" + source.write_bytes(b"bounded") + captured: dict[str, object] = {} + + def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: + captured.update(kwargs) + return np.zeros(policy.max_decoded_samples + 1, dtype=np.float32), policy.target_sample_rate + + monkeypatch.setattr(librosa, "load", fake_load) + monkeypatch.setattr( + librosa.beat, + "beat_track", + lambda **_: (_ for _ in ()).throw(AssertionError("analysis must not run after policy rejection")), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + TemporalAnalyzer(resource_policy=policy).analyze(source) + + assert captured["duration"] == pytest.approx( + (policy.max_decoded_samples + 1) / policy.target_sample_rate + ) + assert captured["sr"] == policy.target_sample_rate + assert captured["mono"] is True + + +def test_stem_decoder_probes_one_sample_past_duration_limit_and_fails_closed( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stem separation consumes the same decoded-sample ceiling as temporal analysis.""" + import librosa + + config = AudioSeparationConfig( + target_sample_rate=8, + max_file_bytes=100, + max_duration_seconds=1.0, + ) + source = tmp_path / "overlong.wav" + source.write_bytes(b"bounded") + captured: dict[str, object] = {} + + def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: + captured.update(kwargs) + return np.zeros(9, dtype=np.float32), 8 + + monkeypatch.setattr(librosa, "load", fake_load) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda *_: (_ for _ in ()).throw(AssertionError("model must not run after policy rejection")), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + AudioStemSeparator(config).separate(source) + + assert captured["duration"] == pytest.approx(9 / 8) + assert captured["sr"] == 8 + assert captured["mono"] is True + + +def test_stem_decoder_rejects_nonfinite_decoded_output_before_model( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Decoder NaN/Inf values fail closed instead of being normalized into model input.""" + import librosa + + source = tmp_path / "nonfinite.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + librosa, + "load", + lambda *args, **kwargs: (np.array([0.0, np.nan], dtype=np.float32), 8), + ) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda *_: (_ for _ in ()).throw(AssertionError("model must not receive non-finite audio")), + ) + + with pytest.raises(ValueError, match="audio resource policy"): + AudioStemSeparator( + AudioSeparationConfig( + target_sample_rate=8, + max_file_bytes=100, + max_duration_seconds=1.0, + ) + ).separate(source) From ee3b48c93104f76d6c10dcc70c0ec0389bbfdc25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:18:18 +0900 Subject: [PATCH 014/448] fix(audio): add one-sample decode probe to resource policy --- .../src/bandscope_analysis/audio_resource_policy.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 4243092bf..14d3e21ef 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -12,6 +12,8 @@ - Decoded audio is revalidated because container metadata and decoder behavior are untrusted; accepted artifacts are finite, mono, at the configured sample rate, and within the configured decoded-sample budget. +- Decoders receive a one-sample-over-budget probe duration so a longer source is + rejected instead of being silently truncated to the accepted duration. - Validation errors are payload-free and never include source paths or audio content. """ @@ -19,6 +21,7 @@ from __future__ import annotations import math +import sys from dataclasses import dataclass from typing import Any, cast @@ -69,12 +72,20 @@ def __post_init__(self) -> None: or float(self.max_duration_seconds) <= 0.0 ): raise ValueError(_POLICY_ERROR) + decoded_samples = self.target_sample_rate * float(self.max_duration_seconds) + if not math.isfinite(decoded_samples) or decoded_samples < 1.0 or decoded_samples > sys.maxsize - 1: + raise ValueError(_POLICY_ERROR) @property def max_decoded_samples(self) -> int: """Return the maximum mono sample count allowed after decoding.""" return int(self.target_sample_rate * float(self.max_duration_seconds)) + @property + def decode_probe_duration_seconds(self) -> float: + """Return a bounded decoder duration that includes one rejection probe sample.""" + return (self.max_decoded_samples + 1) / self.target_sample_rate + def validate_encoded_file_bytes(self, file_size: object) -> int: """Validate an authoritative encoded file size before decoding. From a982e7f70f3167716a017c6dd3f9a29a72ed4e93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:24:07 +0900 Subject: [PATCH 015/448] fix(audio): bind temporal decode to canonical resource policy --- .../bandscope_analysis/temporal/analyzer.py | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 7fe5ae6f7..1584517bd 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -12,14 +12,23 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_DURATION_SECONDS, + AudioResourcePolicy, +) + from .model import TemporalFeatures logger = logging.getLogger(__name__) -# Standard sample rate for BandScope analysis -TARGET_SR = 44100 -MAX_AUDIO_FILE_BYTES = 100 * 1024 * 1024 # 100 MiB -MAX_ANALYSIS_DURATION_SECONDS = 15 * 60 # 15 minutes +# Compatibility aliases retained for callers/tests while the canonical values +# are owned by AudioResourcePolicy. The decode-duration alias intentionally +# includes one rejection-probe sample so an overlong source is detected rather +# than silently truncated at the accepted rehearsal duration. +TARGET_SR = DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate +MAX_AUDIO_FILE_BYTES = DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes +MAX_ANALYSIS_DURATION_SECONDS = DEFAULT_AUDIO_RESOURCE_POLICY.decode_probe_duration_seconds KNOWN_LIBROSA_NUMBA_WARNING_FILTERS = ( (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), (FutureWarning, r".*Numba.*", r".*numba.*"), @@ -57,10 +66,25 @@ def _estimate_downbeats( class TemporalAnalyzer: - """Analyzes temporal features (BPM, beats) from audio files.""" + """Analyze bounded temporal features (BPM and beat grids) from local audio.""" + + def __init__(self, resource_policy: AudioResourcePolicy | None = None) -> None: + """Create an analyzer bound to one canonical local-audio resource policy. + + Args: + resource_policy: Explicit policy for tests or specialized callers. + The default preserves the public module-level byte ceiling while + taking sample-rate and accepted rehearsal duration from the + canonical policy layer. + """ + self.resource_policy = resource_policy or AudioResourcePolicy( + max_encoded_file_bytes=MAX_AUDIO_FILE_BYTES, + target_sample_rate=TARGET_SR, + max_duration_seconds=DEFAULT_MAX_DURATION_SECONDS, + ) def analyze(self, audio_path: str | Path) -> TemporalFeatures: - """Decode audio and extract temporal features. + """Decode bounded audio and extract temporal features. Args: audio_path: Path to the audio file. @@ -78,11 +102,10 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size - if file_size > MAX_AUDIO_FILE_BYTES: - raise ValueError( - f"Audio file is too large for temporal analysis: {file_size} bytes " - f"(max {MAX_AUDIO_FILE_BYTES} bytes)" - ) + try: + self.resource_policy.validate_encoded_file_bytes(file_size) + except ValueError as error: + raise ValueError("Audio file is too large for temporal analysis") from error with warnings.catch_warnings(): warnings.filterwarnings( @@ -99,26 +122,25 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: message=message, module=module, ) - # Load audio, converting to mono and standardizing sample rate + # Decode one sample beyond the accepted duration so longer + # sources fail closed instead of becoming silently truncated. y, sr = librosa.load( fileobj, - sr=TARGET_SR, + sr=self.resource_policy.target_sample_rate, mono=True, - duration=MAX_ANALYSIS_DURATION_SECONDS, + duration=self.resource_policy.decode_probe_duration_seconds, ) - # Ensure it's a 1D float array for librosa + # Preserve the established diagnostic for decoder contract violations + # before applying the canonical numeric policy. if not isinstance(y, np.ndarray): raise ValueError("Expected numpy array from librosa.load") - y_array: NDArray[np.floating[Any]] = y + y_array = self.resource_policy.validate_decoded_audio(y, sr) duration = float(librosa.get_duration(y=y_array, sr=sr)) logger.info("Extracting tempo and beat tracking...") - # Use librosa's robust beat tracker tempo, beat_frames = librosa.beat.beat_track(y=y_array, sr=sr) - - # Convert frame indices to time (seconds) beat_times: NDArray[np.floating[Any]] = librosa.frames_to_time(beat_frames, sr=sr) # Place downbeats on the strongest-onset bar phase (looks at the audio, From 38b3fb6489fe5656cc3ba9ecd8b905af71d53124 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:43:58 +0900 Subject: [PATCH 016/448] fix(audio): enforce canonical policy before stem inference --- .../separation/audio_separator.py | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c36e0f1fc..7b5268ed8 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -9,6 +9,9 @@ Security Notes: - Treats the selected audio file as untrusted input: the path is normalized and verified to be a file, and a maximum byte size is enforced before decode. +- Decoded audio is revalidated against the same versioned resource policy before + Demucs/model work so overlong, malformed, or non-finite decoder output fails + closed instead of being silently truncated or normalized. - Inference runs locally on CPU with no network access. The model weights are loaded from the local Demucs cache or a configured bundled path; offline weight bundling is tracked in the supplemental component inventory. @@ -31,9 +34,12 @@ import librosa import numpy as np +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_MAX_DURATION_SECONDS, + AudioResourcePolicy, +) from bandscope_analysis.temporal.analyzer import ( KNOWN_LIBROSA_NUMBA_WARNING_FILTERS, - MAX_ANALYSIS_DURATION_SECONDS, MAX_AUDIO_FILE_BYTES, TARGET_SR, ) @@ -63,7 +69,7 @@ class AudioSeparationConfig: target_sample_rate: int = TARGET_SR max_file_bytes: int = MAX_AUDIO_FILE_BYTES - max_duration_seconds: float = float(MAX_ANALYSIS_DURATION_SECONDS) + max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) model_name: str = "htdemucs" device: str = "cpu" # Demucs splits long audio into overlapping segments internally, bounding @@ -75,8 +81,13 @@ class AudioStemSeparator: """Split a selected local mix into canonical stems for downstream analysis.""" def __init__(self, config: AudioSeparationConfig | None = None) -> None: - """Initialize the local stem separator (model is loaded lazily).""" + """Initialize the local stem separator and its canonical resource policy.""" self.config = config or AudioSeparationConfig() + self.resource_policy = AudioResourcePolicy( + max_encoded_file_bytes=self.config.max_file_bytes, + target_sample_rate=self.config.target_sample_rate, + max_duration_seconds=self.config.max_duration_seconds, + ) self._model: Any = None def separate(self, audio_path: str | Path) -> AudioSeparationResult: @@ -190,15 +201,16 @@ def _resolve_audio_file(self, audio_path: str | Path) -> Path: return path def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: - """Load bounded mono audio without logging or exposing the full source path.""" + """Load and revalidate bounded mono audio before model inference.""" try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size - if file_size > self.config.max_file_bytes: - raise ValueError( - "Audio file is too large for stem separation: " - f"{file_size} bytes (max {self.config.max_file_bytes} bytes)" - ) + if file_size <= 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + try: + self.resource_policy.validate_encoded_file_bytes(file_size) + except ValueError as error: + raise ValueError("Audio file is too large for stem separation") from error with warnings.catch_warnings(): warnings.filterwarnings( @@ -214,16 +226,19 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: ) y, sr = librosa.load( fileobj, - sr=self.config.target_sample_rate, + sr=self.resource_policy.target_sample_rate, mono=True, - duration=self.config.max_duration_seconds, + duration=self.resource_policy.decode_probe_duration_seconds, ) except ValueError: raise except Exception as error: raise ValueError(f"Stem separation decode failed for {path.name}") from error - return _as_float_array(y), int(sr) + if isinstance(y, np.ndarray) and y.size == 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + validated_audio = self.resource_policy.validate_decoded_audio(y, sr) + return _as_float_array(validated_audio), int(sr) def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: """Trim or pad a stem to match the source length exactly.""" From ed6e4f7bb14518692a8139857bbaa72468b61b11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:47:06 +0900 Subject: [PATCH 017/448] fix(audio): apply canonical byte policy at request preflight --- services/analysis-engine/src/bandscope_analysis/api.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..217e27a42 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -14,6 +14,7 @@ import numpy as np +from bandscope_analysis.audio_resource_policy import DEFAULT_AUDIO_RESOURCE_POLICY from bandscope_analysis.health import HealthReport, build_health_report from bandscope_analysis.roles import RoleExtractor from bandscope_analysis.sections import extract_sections @@ -306,8 +307,12 @@ def validate_analysis_job_request(payload: object) -> AnalysisJobRequest: raise ValueError("Invalid analysis job request: invalid field 'localSource.fileName'") if extension not in {"wav", "mp3", "flac", "m4a"}: raise ValueError("Invalid analysis job request: invalid field 'localSource.extension'") - if not isinstance(file_size_bytes, int) or file_size_bytes <= 0: - raise ValueError("Invalid analysis job request: invalid field 'localSource.fileSizeBytes'") + try: + file_size_bytes = DEFAULT_AUDIO_RESOURCE_POLICY.validate_encoded_file_bytes(file_size_bytes) + except ValueError as error: + raise ValueError( + "Invalid analysis job request: invalid field 'localSource.fileSizeBytes'" + ) from error normalized: AnalysisJobRequest = { "sourceKind": source_kind, From e7671de90cb35138cf27279841745b4975a19fd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:49:56 +0900 Subject: [PATCH 018/448] docs(audio): record resource-boundary evidence --- docs/doctoring/audio-resource-policy.md | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/doctoring/audio-resource-policy.md diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md new file mode 100644 index 000000000..7637aa090 --- /dev/null +++ b/docs/doctoring/audio-resource-policy.md @@ -0,0 +1,27 @@ +# Audio resource policy evidence + +## Scope + +This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. + +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as one-dimensional, non-empty, finite, exactly 44.1 kHz, and no longer than the accepted sample budget before beat tracking or Demucs inference. + +## Evidence-to-control mapping + +| Evidence | BandScope control | +| --- | --- | +| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, sample rate, shape, and finiteness explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | +| OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | +| librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count exceeds the accepted limit. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | + +## Residual risk and follow-up + +This policy bounds the Python local-audio decode and downstream model/beat-analysis entry points. It does not yet establish whole-product parity for the desktop/Rust intake path, source channel/rate metadata, peak-memory estimates, CPU/GPU budgets, cancellation latency, or all external decoder behaviors. Those remain tracked by issue #781 and must be proven before that issue closes. + +## References + +librosa development team. (2025). *librosa.load (librosa 0.11.0)* [Documentation]. https://librosa.org/doc/0.11.0/generated/librosa.load.html + +MITRE Corporation. (2026, April 30). *CWE-770: Allocation of resources without limits or throttling (Version 4.20).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/770.html + +OWASP Foundation. (2025, May). *OWASP Application Security Verification Standard 5.0.0.* https://github.com/OWASP/ASVS/tree/v5.0.0_release/5.0 From 954964797c9de414aadd1dc551937cca897338b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:50:31 +0900 Subject: [PATCH 019/448] docs(changelog): record canonical audio resource bounds --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..dde33a1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Enforce one canonical local-audio resource policy across Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before expensive analysis/model work. + ## [0.1.3] - 2026-04-29 ### Fixed From b35bc12e50ce8971aae895f6187303065a9da720 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:52:29 +0900 Subject: [PATCH 020/448] docs(security): bind local audio to canonical resource policy --- docs/security/app-security.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/security/app-security.md b/docs/security/app-security.md index a9983fb97..8f7d73dd1 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -137,6 +137,8 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Cross-check extension, MIME, and actual decode behavior. - Prefer isolated worker processing for decode and analysis. - Guard against very large files, abnormal duration, and hostile metadata. +- Apply the versioned canonical local-audio resource policy consistently at request preflight and again at the opened-file/decoded-waveform boundary; request metadata is never authoritative for actual resource use. +- In the Python analysis boundary, reject decoded audio that is empty, non-finite, wrong-rate, wrong-shaped, or over the accepted sample budget before beat tracking or model inference. Use the one-sample-over decode probe described in `docs/doctoring/audio-resource-policy.md` so an exact-boundary track remains accepted while excess decoded output is observable and fails closed. - Do not add arbitrary filesystem scanning just to find media files. - When bootstrapping a project around local audio, prefer referencing the validated original file plus app-owned temp/cache/project roots over copying the file until persistence requirements justify the extra storage boundary. From 710ed165b00d61d454b0bcd74d1215c7e0a0bcf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:53:25 +0900 Subject: [PATCH 021/448] test(audio): reject resource-policy arithmetic overflow --- .../analysis-engine/tests/test_audio_resource_policy.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index ca48b0980..85221acaf 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -81,3 +81,9 @@ def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> """Invalid policy construction cannot silently create an unbounded budget.""" with pytest.raises(ValueError, match="audio resource policy"): AudioResourcePolicy(**kwargs) # type: ignore[arg-type] + + +def test_policy_configuration_fails_closed_on_unrepresentable_sample_budget() -> None: + """Extreme integer metadata cannot escape the policy through float conversion overflow.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(target_sample_rate=10**400, max_duration_seconds=1.0) From 6e8052740c5340259257636b0d3c00a9319e86b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:54:47 +0900 Subject: [PATCH 022/448] fix(audio): fail closed on extreme sample-rate arithmetic --- .../src/bandscope_analysis/audio_resource_policy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 14d3e21ef..b1e15d7f9 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -63,6 +63,7 @@ def __post_init__(self) -> None: isinstance(self.target_sample_rate, bool) or not isinstance(self.target_sample_rate, int) or self.target_sample_rate <= 0 + or self.target_sample_rate > sys.maxsize - 1 ): raise ValueError(_POLICY_ERROR) if ( From 572333e8657d26bb019aae38164b5928426f0d76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:56:12 +0900 Subject: [PATCH 023/448] style(audio): keep policy bounds formatter-clean --- .../src/bandscope_analysis/audio_resource_policy.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index b1e15d7f9..f89476e87 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -74,7 +74,11 @@ def __post_init__(self) -> None: ): raise ValueError(_POLICY_ERROR) decoded_samples = self.target_sample_rate * float(self.max_duration_seconds) - if not math.isfinite(decoded_samples) or decoded_samples < 1.0 or decoded_samples > sys.maxsize - 1: + if ( + not math.isfinite(decoded_samples) + or decoded_samples < 1.0 + or decoded_samples > sys.maxsize - 1 + ): raise ValueError(_POLICY_ERROR) @property From adbfad0d7bc6e6e53dfbd52d617b860e27030fee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:02:22 +0900 Subject: [PATCH 024/448] test(audio): reject unrepresentable policy and decoder values --- .../tests/test_audio_resource_policy.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index 85221acaf..b670f31c0 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -43,6 +43,8 @@ def test_encoded_file_size_accepts_exact_boundary() -> None: (np.zeros(8_001, dtype=np.float32), 8_000), (np.zeros((2, 4_000), dtype=np.float32), 8_000), (np.array([0.0, np.nan], dtype=np.float32), 8_000), + (np.array(["not-a-sample"], dtype=object), 8_000), + (np.zeros(10, dtype=np.int16), 8_000), (np.zeros(10, dtype=np.float32), 0), (np.zeros(10, dtype=np.float32), True), ], @@ -51,7 +53,7 @@ def test_decoded_audio_fails_closed_outside_policy( audio: np.ndarray, sample_rate: object, ) -> None: - """Decoded output is revalidated for shape, finiteness, rate, and sample budget.""" + """Decoded output is revalidated for type, shape, finiteness, rate, and sample budget.""" policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) with pytest.raises(ValueError, match="audio resource policy"): @@ -83,7 +85,17 @@ def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> AudioResourcePolicy(**kwargs) # type: ignore[arg-type] -def test_policy_configuration_fails_closed_on_unrepresentable_sample_budget() -> None: - """Extreme integer metadata cannot escape the policy through float conversion overflow.""" +@pytest.mark.parametrize( + "kwargs", + [ + {"target_sample_rate": 10**400, "max_duration_seconds": 1.0}, + {"target_sample_rate": 1, "max_duration_seconds": 10**400}, + {"max_encoded_file_bytes": 10**400}, + ], +) +def test_policy_configuration_fails_closed_on_unrepresentable_limits( + kwargs: dict[str, object], +) -> None: + """Extreme integer limits cannot escape stable policy validation through overflow.""" with pytest.raises(ValueError, match="audio resource policy"): - AudioResourcePolicy(target_sample_rate=10**400, max_duration_seconds=1.0) + AudioResourcePolicy(**kwargs) # type: ignore[arg-type] From 428db0a4096606ce959b831433b822d18b8870a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:02:55 +0900 Subject: [PATCH 025/448] fix(audio): make resource policy arithmetic fully fail closed --- .../audio_resource_policy.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index f89476e87..1fae11c4f 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -10,10 +10,12 @@ - Encoded byte counts are validated before decode/allocation work when the opened file descriptor can provide an authoritative size. - Decoded audio is revalidated because container metadata and decoder behavior - are untrusted; accepted artifacts are finite, mono, at the configured sample - rate, and within the configured decoded-sample budget. + are untrusted; accepted artifacts are finite, mono, floating-point, at the + configured sample rate, and within the configured decoded-sample budget. - Decoders receive a one-sample-over-budget probe duration so a longer source is rejected instead of being silently truncated to the accepted duration. +- Policy arithmetic rejects unrepresentable limits before float/sample-count + conversion so malformed configuration cannot escape the stable failure mode. - Validation errors are payload-free and never include source paths or audio content. """ @@ -57,6 +59,7 @@ def __post_init__(self) -> None: isinstance(self.max_encoded_file_bytes, bool) or not isinstance(self.max_encoded_file_bytes, int) or self.max_encoded_file_bytes <= 0 + or self.max_encoded_file_bytes > sys.maxsize - 1 ): raise ValueError(_POLICY_ERROR) if ( @@ -66,14 +69,17 @@ def __post_init__(self) -> None: or self.target_sample_rate > sys.maxsize - 1 ): raise ValueError(_POLICY_ERROR) - if ( - isinstance(self.max_duration_seconds, bool) - or not isinstance(self.max_duration_seconds, int | float) - or not math.isfinite(float(self.max_duration_seconds)) - or float(self.max_duration_seconds) <= 0.0 + if isinstance(self.max_duration_seconds, bool) or not isinstance( + self.max_duration_seconds, int | float ): raise ValueError(_POLICY_ERROR) - decoded_samples = self.target_sample_rate * float(self.max_duration_seconds) + try: + duration_seconds = float(self.max_duration_seconds) + except (OverflowError, ValueError): + raise ValueError(_POLICY_ERROR) from None + if not math.isfinite(duration_seconds) or duration_seconds <= 0.0: + raise ValueError(_POLICY_ERROR) + decoded_samples = self.target_sample_rate * duration_seconds if ( not math.isfinite(decoded_samples) or decoded_samples < 1.0 @@ -124,13 +130,18 @@ def validate_decoded_audio( sample_rate: Decoder-reported sample rate in Hz. Returns: - The original validated NumPy array without copying it. + The original validated NumPy floating-point array without copying it. Raises: - ValueError: If shape, sample rate, sample count, or finiteness does - not satisfy this policy. + ValueError: If dtype, shape, sample rate, sample count, or finiteness + does not satisfy this policy. """ - if not isinstance(audio, np.ndarray) or audio.ndim != 1 or audio.size == 0: + if ( + not isinstance(audio, np.ndarray) + or audio.ndim != 1 + or audio.size == 0 + or not np.issubdtype(audio.dtype, np.floating) + ): raise ValueError(_POLICY_ERROR) if ( isinstance(sample_rate, bool) From 84f60b3baee8ca5f89386b86c107f79b19e494ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:03:23 +0900 Subject: [PATCH 026/448] docs(audio): record checked policy arithmetic --- docs/doctoring/audio-resource-policy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index 7637aa090..1083e8e2b 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -4,13 +4,13 @@ This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. -The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as one-dimensional, non-empty, finite, exactly 44.1 kHz, and no longer than the accepted sample budget before beat tracking or Demucs inference. +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and no longer than the accepted sample budget before beat tracking or Demucs inference. Policy construction also rejects byte, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. ## Evidence-to-control mapping | Evidence | BandScope control | | --- | --- | -| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, sample rate, shape, and finiteness explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | +| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | | OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | | librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count exceeds the accepted limit. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | From e42ff5c4903b5dc1db8aec4ff348e0a1473c6e5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:27:09 +0900 Subject: [PATCH 027/448] test(audio): reject oversized desktop selection at the bridge --- apps/desktop/src/lib/analysis.test.ts | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..462f2aad3 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; import { + MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + selectLocalAudioSource, startAnalysisJob } from "./analysis"; @@ -20,6 +22,32 @@ describe("analysis bridge", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("rejects an oversized native local-audio selection before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-local-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-local-project", + cacheRoot: "/tmp/bandscope/cache/native-local-project", + tempRoot: "/tmp/bandscope/temp/native-local-project", + source: { + sourcePath: "/tmp/bandscope/input.wav", + fileName: "input.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Selected audio file exceeds the 100 MiB analysis limit." + } + }); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); From 05fbbb32a8ccd120d8e665b1e9e0c123baa2cff5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:27:51 +0900 Subject: [PATCH 028/448] test(audio): enforce encoded-byte parity for imported sources --- apps/desktop/src/lib/analysis.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index 462f2aad3..62170fd23 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -48,6 +48,32 @@ describe("analysis bridge", () => { }); }); + it("rejects an oversized native YouTube import before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-youtube-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-youtube-project", + cacheRoot: "/tmp/bandscope/cache/native-youtube-project", + tempRoot: "/tmp/bandscope/temp/native-youtube-project", + source: { + sourcePath: "/tmp/bandscope/temp/native-youtube-project/youtube.wav", + fileName: "youtube.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await importYoutubeUrl("https://youtu.be/4ozX4yFUC34"); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Selected audio file exceeds the 100 MiB analysis limit." + } + }); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); From b23e51a1a4141bf385b919b74d2d726f8b038428 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:28:27 +0900 Subject: [PATCH 029/448] fix(audio): enforce encoded-byte parity at desktop bridge --- apps/desktop/src/lib/analysis.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..d4292c640 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -35,8 +35,11 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Selected audio file exceeds the 100 MiB analysis limit."; +const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, + LOCAL_AUDIO_TOO_LARGE_MESSAGE, "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", @@ -45,7 +48,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; -export { MAX_YOUTUBE_URL_LENGTH }; +export { MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH }; /** Documented. */ export type LocalAudioSelectionResult = @@ -217,6 +220,22 @@ async function invokeAnalysis(command: string, args?: Record): return browserFallback(command, args); } +/** + * Parse a native/import bootstrap and enforce policy-v1 encoded-byte parity + * before the selection is allowed to become desktop project state. + * + * Python service and descriptor checks remain authoritative for analysis; this + * bridge check is defense in depth so local-file and imported-file intake fail + * at the same 100 MiB boundary instead of waiting for a later analysis stage. + */ +function parseBoundedAudioBootstrap(response: unknown): ProjectBootstrapSummary { + const bootstrap = parseProjectBootstrapSummary(response); + if (bootstrap.source.fileSizeBytes > MAX_LOCAL_AUDIO_FILE_BYTES) { + throw new Error(LOCAL_AUDIO_TOO_LARGE_MESSAGE); + } + return bootstrap; +} + /** Documented. */ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); @@ -228,7 +247,7 @@ export async function selectLocalAudioSource(): Promise Date: Sun, 16 Aug 2026 22:29:03 +0900 Subject: [PATCH 030/448] docs(changelog): record desktop audio-policy parity --- CHANGELOG.md | 45 ++++++++++++++------------------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde33a1b9..a3f5d196b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Enforce one canonical local-audio resource policy across Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before expensive analysis/model work. +- Enforce one canonical local-audio resource policy across desktop bridge intake, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before it becomes project state or reaches expensive analysis/model work. ## [0.1.3] - 2026-04-29 @@ -33,40 +33,23 @@ ### Added -- Implemented rehearsal workspace design (Issue #107) -- Add capo and tuning detection heuristics (Issue #103) -- Add bandit security scan workflow +- Added a deterministic rehearsal planner output contract for section order, role priorities, handoff cues, and export summaries. +- Added local-first YouTube import fallback behavior with explicit source labeling and no credential storage. +- Added project score PDF attachment metadata and app-owned local score storage. + +### Changed + +- Hardened local audio intake and project bootstrap around app-owned project/cache/temp roots. +- Tightened analysis-job payload parsing, status validation, and desktop/native bridge behavior. +- Expanded deterministic music-analysis fixtures and release-preflight coverage. ### Fixed -- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g -- Resolve npm audit vulnerabilities -- Fix ruff import sorting and formatting errors -- Add missing docstrings to tests -- Fix test configuration and typing issues +- Prevented malformed project, audio, score, and bridge payloads from silently reaching downstream analysis or persistence boundaries. -## [0.1.0] - 2026-03-27 +## [0.1.0] - 2026-04-27 ### Added -- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) -- Issue #40: Enforced 100% Python docstring and test coverage -- Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- Implemented secure local audio intake and project bootstrap. +- Added the first local-first rehearsal workspace, project persistence flow, and bounded analysis bridge. From 9823e0e564f130ddfd37388d6845046fea0f7d05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:29:23 +0900 Subject: [PATCH 031/448] fix(changelog): restore full release history after parity note drift --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f5d196b..dde33a1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Enforce one canonical local-audio resource policy across desktop bridge intake, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before it becomes project state or reaches expensive analysis/model work. +- Enforce one canonical local-audio resource policy across Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before expensive analysis/model work. ## [0.1.3] - 2026-04-29 @@ -33,23 +33,40 @@ ### Added -- Added a deterministic rehearsal planner output contract for section order, role priorities, handoff cues, and export summaries. -- Added local-first YouTube import fallback behavior with explicit source labeling and no credential storage. -- Added project score PDF attachment metadata and app-owned local score storage. - -### Changed - -- Hardened local audio intake and project bootstrap around app-owned project/cache/temp roots. -- Tightened analysis-job payload parsing, status validation, and desktop/native bridge behavior. -- Expanded deterministic music-analysis fixtures and release-preflight coverage. +- Implemented rehearsal workspace design (Issue #107) +- Add capo and tuning detection heuristics (Issue #103) +- Add bandit security scan workflow ### Fixed -- Prevented malformed project, audio, score, and bridge payloads from silently reaching downstream analysis or persistence boundaries. +- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g +- Resolve npm audit vulnerabilities +- Fix ruff import sorting and formatting errors +- Add missing docstrings to tests +- Fix test configuration and typing issues -## [0.1.0] - 2026-04-27 +## [0.1.0] - 2026-03-27 ### Added -- Implemented secure local audio intake and project bootstrap. -- Added the first local-first rehearsal workspace, project persistence flow, and bounded analysis bridge. +- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #40: Enforced 100% Python docstring and test coverage +- Issue #32: Implemented local analysis orchestration and secure IPC boundaries +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From f6310498ad7a0b814c4d23ed68007e1024adbc29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:03:15 +0900 Subject: [PATCH 032/448] fix(ci): format audio resource policy regressions --- .../tests/test_audio_resource_policy.py | 2 +- .../test_audio_resource_policy_integration.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index b670f31c0..75e2871df 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -7,8 +7,8 @@ from bandscope_analysis.audio_resource_policy import ( AUDIO_RESOURCE_POLICY_VERSION, - AudioResourcePolicy, DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, ) diff --git a/services/analysis-engine/tests/test_audio_resource_policy_integration.py b/services/analysis-engine/tests/test_audio_resource_policy_integration.py index 36f9c851a..ec44a93d3 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy_integration.py +++ b/services/analysis-engine/tests/test_audio_resource_policy_integration.py @@ -7,8 +7,8 @@ from bandscope_analysis.api import validate_analysis_job_request from bandscope_analysis.audio_resource_policy import ( - AudioResourcePolicy, DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, ) from bandscope_analysis.separation.audio_separator import ( AudioSeparationConfig, @@ -51,7 +51,10 @@ def test_request_preflight_accepts_exact_encoded_byte_boundary() -> None: _local_request(DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes) ) - assert request["localSource"]["fileSizeBytes"] == DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + assert ( + request["localSource"]["fileSizeBytes"] + == DEFAULT_AUDIO_RESOURCE_POLICY.max_encoded_file_bytes + ) def test_temporal_decoder_probes_one_sample_past_duration_limit_and_fails_closed( @@ -78,7 +81,9 @@ def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: monkeypatch.setattr( librosa.beat, "beat_track", - lambda **_: (_ for _ in ()).throw(AssertionError("analysis must not run after policy rejection")), + lambda **_: (_ for _ in ()).throw( + AssertionError("analysis must not run after policy rejection") + ), ) with pytest.raises(ValueError, match="audio resource policy"): @@ -115,7 +120,9 @@ def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: monkeypatch.setattr( AudioStemSeparator, "_separate_signal", - lambda *_: (_ for _ in ()).throw(AssertionError("model must not run after policy rejection")), + lambda *_: (_ for _ in ()).throw( + AssertionError("model must not run after policy rejection") + ), ) with pytest.raises(ValueError, match="audio resource policy"): From 0ba05930b3251c39325bca22897bd4b438113b88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:12:31 +0900 Subject: [PATCH 033/448] test(audio): reject fractional encoded byte metadata --- .../src/lib/analysis.resource-policy.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.resource-policy.test.ts diff --git a/apps/desktop/src/lib/analysis.resource-policy.test.ts b/apps/desktop/src/lib/analysis.resource-policy.test.ts new file mode 100644 index 000000000..33ab81804 --- /dev/null +++ b/apps/desktop/src/lib/analysis.resource-policy.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importYoutubeUrl, selectLocalAudioSource } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const INVALID_RESOURCE_POLICY_MESSAGE = + "Selected audio file metadata violates the analysis resource policy."; + +function fractionalBootstrap(projectId: string) { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/cache/${projectId}`, + tempRoot: `/tmp/bandscope/temp/${projectId}`, + source: { + sourcePath: `/tmp/bandscope/${projectId}/input.wav`, + fileName: "input.wav", + extension: "wav", + fileSizeBytes: 1.5 + } + }; +} + +describe("analysis encoded-byte policy parity", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("rejects fractional local-file metadata before project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(fractionalBootstrap("local-project")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: INVALID_RESOURCE_POLICY_MESSAGE + } + }); + }); + + it("rejects fractional imported-file metadata before project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(fractionalBootstrap("youtube-project")); + + await expect(importYoutubeUrl("https://youtu.be/4ozX4yFUC34")).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: INVALID_RESOURCE_POLICY_MESSAGE + } + }); + }); +}); From ceb7f71a9d7bb653b940649d9394c20b4f313e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:13:35 +0900 Subject: [PATCH 034/448] fix(audio): require integral encoded byte metadata --- apps/desktop/src/lib/analysis.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index d4292c640..6ff443320 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -36,10 +36,13 @@ const BROWSER_PROGRESS_STEPS = [ ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Selected audio file exceeds the 100 MiB analysis limit."; +const LOCAL_AUDIO_POLICY_MESSAGE = + "Selected audio file metadata violates the analysis resource policy."; const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, LOCAL_AUDIO_TOO_LARGE_MESSAGE, + LOCAL_AUDIO_POLICY_MESSAGE, "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", @@ -230,7 +233,11 @@ async function invokeAnalysis(command: string, args?: Record): */ function parseBoundedAudioBootstrap(response: unknown): ProjectBootstrapSummary { const bootstrap = parseProjectBootstrapSummary(response); - if (bootstrap.source.fileSizeBytes > MAX_LOCAL_AUDIO_FILE_BYTES) { + const fileSizeBytes = bootstrap.source.fileSizeBytes; + if (!Number.isSafeInteger(fileSizeBytes)) { + throw new Error(LOCAL_AUDIO_POLICY_MESSAGE); + } + if (fileSizeBytes > MAX_LOCAL_AUDIO_FILE_BYTES) { throw new Error(LOCAL_AUDIO_TOO_LARGE_MESSAGE); } return bootstrap; From 84f7691b24b803a08f90916b20b69f3c5288b761 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:19:41 +0900 Subject: [PATCH 035/448] test(audio): require native encoded-byte admission --- .../core/tests/audio_resource_policy.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/core/tests/audio_resource_policy.rs diff --git a/apps/desktop/core/tests/audio_resource_policy.rs b/apps/desktop/core/tests/audio_resource_policy.rs new file mode 100644 index 000000000..11c6725ca --- /dev/null +++ b/apps/desktop/core/tests/audio_resource_policy.rs @@ -0,0 +1,25 @@ +use bandscope_desktop_core::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; + +#[test] +fn local_audio_size_policy_accepts_the_exact_native_bootstrap_ceiling() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES), + Ok(MAX_LOCAL_AUDIO_FILE_BYTES) + ); +} + +#[test] +fn local_audio_size_policy_rejects_an_empty_native_bootstrap_source() { + assert_eq!( + validate_local_audio_file_size(0), + Err("Could not read the selected audio file.".to_string()) + ); +} + +#[test] +fn local_audio_size_policy_rejects_a_native_source_above_the_canonical_ceiling() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES + 1), + Err("Selected audio file exceeds the 100 MiB analysis limit.".to_string()) + ); +} From 0488c8f020dfcb3b605ef98ab08a24ac8a54a7ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:39:38 +0900 Subject: [PATCH 036/448] fix(audio): enforce native encoded-byte ceiling --- apps/desktop/core/src/audio_resource.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/desktop/core/src/audio_resource.rs diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs new file mode 100644 index 000000000..88c07e425 --- /dev/null +++ b/apps/desktop/core/src/audio_resource.rs @@ -0,0 +1,22 @@ +/// Maximum encoded local-audio file size accepted by the desktop bootstrap boundary. +pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; + +const LOCAL_AUDIO_READ_ERROR: &str = "Could not read the selected audio file."; +const LOCAL_AUDIO_TOO_LARGE_ERROR: &str = + "Selected audio file exceeds the 100 MiB analysis limit."; + +/// Validate a native local-audio file length before storing bootstrap metadata. +/// +/// The caller must obtain this length from the native filesystem descriptor or +/// metadata boundary rather than from renderer-controlled JSON. The function +/// intentionally returns only bounded product messages and never includes a +/// local path or payload content. +pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result { + if file_size_bytes == 0 { + return Err(LOCAL_AUDIO_READ_ERROR.to_string()); + } + if file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES { + return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); + } + Ok(file_size_bytes) +} From fbc7d7dbe07c7937e3c45ad9c08a9aa389d7c84c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:39:48 +0900 Subject: [PATCH 037/448] fix(audio): export native resource policy --- apps/desktop/core/src/root.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 6dd1f4fc5..125d13daa 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -1,13 +1,15 @@ //! Pure, GUI-independent logic for the BandScope desktop application. //! //! The historical desktop-core implementation remains in `lib.rs` as the -//! compatibility module while bounded score-file I/O is isolated in its own -//! auditable module. Public symbols are re-exported so downstream callers keep +//! compatibility module while bounded resource boundaries are isolated in +//! auditable modules. Public symbols are re-exported so downstream callers keep //! the same crate-root API. #[path = "lib.rs"] mod runtime_core; +mod audio_resource; mod score_pdf; +pub use audio_resource::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; From e61e858e2f40a77db26828214bffd51d0a793544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:41:32 +0900 Subject: [PATCH 038/448] fix(audio): enforce native bootstrap byte ceiling --- apps/desktop/src-tauri/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 94b6c1a61..1a78bfbf3 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -155,9 +155,10 @@ fn normalize_local_audio_source(path: &Path) -> Result Result Date: Sun, 16 Aug 2026 23:42:38 +0900 Subject: [PATCH 039/448] docs(audio): record native intake enforcement --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701a41f9a..3f886dbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Enforce one canonical local-audio resource policy across Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite decoded input fails before expensive analysis/model work. +- Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. ## [0.1.3] - 2026-04-29 From 2b9d5e341eb2177a444ec9e0f9509ba4e56a192a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:00:37 +0000 Subject: [PATCH 040/448] test(audio): require YouTube download to use canonical 100 MiB policy A 60 MiB import must be accepted, exact 100 MiB must pass, and announced/in-flight/post-download oversize must fail before cache fill. Co-authored-by: Seongho Bae --- .../analysis-engine/tests/test_youtube.py | 232 +++++++++++++++++- 1 file changed, 229 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 5531ac9d5..7ed39e00c 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -7,7 +7,13 @@ import pytest import yt_dlp # type: ignore -from bandscope_analysis.youtube import MAX_YOUTUBE_URL_LENGTH, download_youtube_audio, validate_url +from bandscope_analysis.audio_resource_policy import DEFAULT_MAX_ENCODED_FILE_BYTES +from bandscope_analysis.youtube import ( + MAX_YOUTUBE_URL_LENGTH, + YOUTUBE_SIZE_EXCEEDED_MESSAGE, + download_youtube_audio, + validate_url, +) def test_validate_url() -> None: @@ -89,6 +95,8 @@ def test_download_youtube_audio_success( "id": "abc123DEF45", "title": "Test Video", "duration": 60, + "filesize": True, + "filesize_approx": float("nan"), } mock_ydl.extract_info.return_value = mock_info mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.webm" @@ -114,6 +122,8 @@ def test_download_youtube_audio_success( assert called_opts["noplaylist"] is True assert called_opts["geo_bypass"] is False assert called_opts["postprocessors"] == [{"key": "FFmpegExtractAudio"}] + assert called_opts["max_filesize"] == DEFAULT_MAX_ENCODED_FILE_BYTES + assert called_opts["progress_hooks"] assert "%(id)s.%(ext)s" in called_opts["outtmpl"] # Verify extract_info was called twice correctly: once for metadata, once for download @@ -273,6 +283,49 @@ def test_download_youtube_audio_duration_exceeded(mock_ydl_class: MagicMock) -> assert result["error"]["code"] == "duration_exceeded" +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_accepts_size_between_legacy_and_canonical_ceiling( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """A 60 MiB download that the old 50 MB check rejected is now accepted.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = 60 * 1024 * 1024 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is True + assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.m4a" + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_accepts_exact_policy_ceiling( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """An encoded YouTube file exactly at the 100 MiB ceiling is accepted.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is True + + @patch("bandscope_analysis.youtube.os.path.getsize") @patch("bandscope_analysis.youtube.os.path.exists") @patch("bandscope_analysis.youtube.os.remove") @@ -283,20 +336,193 @@ def test_download_youtube_audio_size_exceeded( mock_exists: MagicMock, mock_getsize: MagicMock, ) -> None: - """Test download fails if size exceeds 50MB.""" + """Post-download files one byte over the canonical 100 MiB ceiling are deleted.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" mock_exists.return_value = True - mock_getsize.return_value = 51 * 1024 * 1024 + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + 1 result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") assert result["ok"] is False assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE mock_remove.assert_called_with("/tmp/abc123DEF45.m4a") +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.os.remove") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_oversize_skips_remove_when_file_already_gone( + mock_ydl_class: MagicMock, + mock_remove: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """A vanished oversize artifact still fails closed without a remove race.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.side_effect = [True, False] + mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + 1 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + mock_remove.assert_not_called() + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_rejects_announced_filesize_before_download( + mock_ydl_class: MagicMock, +) -> None: + """Announced filesize over the policy ceiling must not start the download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "duration": 60, + "filesize": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + } + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + mock_ydl.extract_info.assert_called_once_with( + "https://youtube.com/watch?v=abc123DEF45", + download=False, + ) + + +@pytest.mark.parametrize( + "info", + [ + { + "id": "abc123DEF45", + "duration": 60, + "filesize_approx": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + }, + { + "id": "abc123DEF45", + "duration": 60, + "filesize_approx": float(DEFAULT_MAX_ENCODED_FILE_BYTES) + 0.5, + }, + ], +) +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_rejects_announced_approximate_oversize( + mock_ydl_class: MagicMock, + info: dict[str, object], +) -> None: + """Approximate oversize metadata rejects the import before download starts.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = info + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + mock_ydl.extract_info.assert_called_once() + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_aborts_over_budget( + mock_ydl_class: MagicMock, +) -> None: + """In-flight progress that crosses the encoded-byte ceiling fails closed.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + + def extract_info(_url: str, download: bool = False) -> dict[str, object]: + """Invoke the registered progress hook when the download starts.""" + if download: + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook( + { + "status": "downloading", + "downloaded_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + } + ) + return {"id": "abc123DEF45", "duration": 60} + + mock_ydl.extract_info.side_effect = extract_info + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_ignores_non_budget_updates( + mock_ydl_class: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """Unknown statuses and non-integer byte fields do not abort a valid download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 60} + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = 10 * 1024 * 1024 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook({"status": "error"}) + hook({"status": "downloading", "downloaded_bytes": True}) + hook({"status": "downloading", "downloaded_bytes": 12.5}) + hook({"status": "downloading", "downloaded_bytes": 10}) + hook({"status": "finished", "total_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES}) + + assert result["ok"] is True + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_maps_max_filesize_download_error( + mock_ydl_class: MagicMock, +) -> None: + """yt-dlp max-filesize aborts become the payload-safe size-exceeded result.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.side_effect = yt_dlp.utils.DownloadError( + "File is larger than max-filesize" + ) + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + assert "max-filesize" not in result["error"]["message"] + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_maps_mib_limit_download_error( + mock_ydl_class: MagicMock, +) -> None: + """Download errors that mention the 100 MiB ceiling stay payload-safe.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.side_effect = yt_dlp.utils.DownloadError(YOUTUBE_SIZE_EXCEEDED_MESSAGE) + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + + def test_main_block(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: """Test the CLI entry point.""" test_args = [ From 19064f425b75879424ae565718fd654377a2efc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:00:37 +0000 Subject: [PATCH 041/448] fix(audio): abort YouTube downloads at the canonical encoded-byte ceiling Drive yt-dlp max_filesize and a progress hook from AudioResourcePolicy, reject announced oversize before download=True, and delete artifacts that still exceed 100 MiB after write. Co-authored-by: Seongho Bae --- .../src/bandscope_analysis/youtube.py | 132 ++++++++++++++++-- 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index c98f4e513..7f0d11e10 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -1,11 +1,26 @@ -""" -YouTube import capabilities for BandScope. +"""YouTube import capabilities for BandScope. This module provides a safe wrapper around yt-dlp to download audio from YouTube. + +Security Notes: + - URL intake remains host/path/query allowlisted before any network work. + - Encoded-byte admission uses the same canonical 100 MiB policy as local + audio. yt-dlp ``max_filesize`` and a progress hook abort in-flight + transfers so a multi-gigabyte download cannot fill the cache root before + the post-download check runs. + - Announced ``filesize`` / ``filesize_approx`` values over the policy + ceiling reject the import before ``download=True``. + - The opened-file size is revalidated with ``AudioResourcePolicy`` after + download; oversize artifacts are deleted. + - Validation errors are payload-free and never include source paths, URLs, + cookies, or audio content. """ +from __future__ import annotations + import argparse import json +import math import os import re import sys @@ -14,6 +29,12 @@ import yt_dlp # type: ignore +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_DURATION_SECONDS, + DEFAULT_MAX_ENCODED_FILE_BYTES, +) + YOUTUBE_VIDEO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$") MAX_YOUTUBE_URL_LENGTH = 2000 SUPPORTED_AUDIO_EXTENSIONS = (".opus", ".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg") @@ -21,6 +42,22 @@ "Failed to download audio from YouTube. Please use a local audio file instead." ) YOUTUBE_IMPORT_FAILED_MESSAGE = "YouTube import failed. Please use a local audio file instead." +YOUTUBE_SIZE_EXCEEDED_MESSAGE = "Selected audio file exceeds the 100 MiB analysis limit." + + +class YoutubeResourceLimitError(Exception): + """Fail-closed YouTube admission error that never includes payload paths.""" + + def __init__(self, code: str, message: str) -> None: + """Store a payload-safe public error code and next-action message. + + Args: + code: Stable machine-readable error code. + message: User-facing instruction that omits paths and URLs. + """ + super().__init__(message) + self.code = code + self.message = message def validate_url(url: str) -> bool: @@ -72,9 +109,71 @@ def _find_downloaded_file(actual_filepath: str) -> Optional[str]: return actual_filepath +def _size_exceeded_result() -> Dict[str, Any]: + """Return the payload-safe oversize result shared by every admission path.""" + return { + "ok": False, + "error": { + "code": "size_exceeded", + "message": YOUTUBE_SIZE_EXCEEDED_MESSAGE, + }, + } + + +def _announced_size_exceeds_policy(announced: object) -> bool: + """Return whether yt-dlp metadata already reports an over-budget file. + + Args: + announced: Candidate ``filesize`` or ``filesize_approx`` value. + + Returns: + True when the value is a finite number strictly above the policy ceiling. + """ + if isinstance(announced, bool) or not isinstance(announced, int | float): + return False + if isinstance(announced, float) and not math.isfinite(announced): + return False + size_bytes: int | float = announced + return bool(size_bytes > DEFAULT_MAX_ENCODED_FILE_BYTES) + + +def _reject_announced_oversize(info: dict[str, Any]) -> Dict[str, Any] | None: + """Reject before download when extract_info already announced oversize bytes. + + Args: + info: Metadata dictionary from ``extract_info(..., download=False)``. + + Returns: + The size-exceeded result, or ``None`` when download may proceed. + """ + if _announced_size_exceeds_policy(info.get("filesize")) or _announced_size_exceeds_policy( + info.get("filesize_approx") + ): + return _size_exceeded_result() + return None + + +def _abort_over_budget_download(status: dict[str, Any]) -> None: + """Abort an in-flight download once encoded bytes exceed the policy ceiling. + + Args: + status: yt-dlp progress-hook payload. Unknown statuses are ignored. + """ + if status.get("status") not in {"downloading", "finished"}: + return + for key in ("downloaded_bytes", "total_bytes", "total_bytes_estimate"): + candidate = status.get(key) + if isinstance(candidate, bool) or not isinstance(candidate, int): + continue + if candidate > DEFAULT_MAX_ENCODED_FILE_BYTES: + raise YoutubeResourceLimitError("size_exceeded", YOUTUBE_SIZE_EXCEEDED_MESSAGE) + + def _handle_download_error(e: yt_dlp.utils.DownloadError) -> Dict[str, Any]: """Map yt-dlp DownloadError to the public YouTube import error response.""" msg = str(e).lower() + if "max-filesize" in msg or "100 mib" in msg: + return _size_exceeded_result() if ( "sign in" in msg or "members-only" in msg @@ -130,6 +229,8 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "noplaylist": True, "postprocessors": [{"key": "FFmpegExtractAudio"}], "geo_bypass": False, + "max_filesize": DEFAULT_MAX_ENCODED_FILE_BYTES, + "progress_hooks": [_abort_over_budget_download], } try: @@ -138,7 +239,7 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: if info is None: raise Exception("Failed to extract info") duration = info.get("duration") - if duration is not None and duration > 15 * 60: + if duration is not None and duration > DEFAULT_MAX_DURATION_SECONDS: return { "ok": False, "error": { @@ -146,6 +247,9 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "message": "Video exceeds the 15-minute limit.", }, } + announced_rejection = _reject_announced_oversize(info) + if announced_rejection is not None: + return announced_rejection info = ydl.extract_info(url, download=True) if info is None: @@ -163,18 +267,14 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } - if ( - os.path.exists(actual_filepath) - and os.path.getsize(actual_filepath) > 50 * 1024 * 1024 - ): - os.remove(actual_filepath) - return { - "ok": False, - "error": { - "code": "size_exceeded", - "message": "Downloaded file exceeds the 50MB limit.", - }, - } + try: + DEFAULT_AUDIO_RESOURCE_POLICY.validate_encoded_file_bytes( + os.path.getsize(actual_filepath) + ) + except ValueError: + if os.path.exists(actual_filepath): + os.remove(actual_filepath) + return _size_exceeded_result() return { "ok": True, "metadata": { @@ -184,6 +284,8 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "filepath": actual_filepath, }, } + except YoutubeResourceLimitError: + return _size_exceeded_result() except yt_dlp.utils.DownloadError as e: return _handle_download_error(e) except Exception: From 7b3b1e75ea8486869d0cc887122c9999ae2667fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:00:37 +0000 Subject: [PATCH 042/448] docs(audio): record YouTube download-time policy evidence Update residual-risk text so Rust intake is no longer described as missing, and cite yt-dlp max_filesize/progress_hooks in APA 7th. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring/audio-resource-policy.md | 9 +++++++-- docs/security/app-security.md | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f886dbb5..95a3d09d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. ## [0.1.3] - 2026-04-29 diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index 1083e8e2b..dc45b17c0 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -4,7 +4,7 @@ This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. -The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and no longer than the accepted sample budget before beat tracking or Demucs inference. Policy construction also rejects byte, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and no longer than the accepted sample budget before beat tracking or Demucs inference. Policy construction also rejects byte, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. ## Evidence-to-control mapping @@ -13,10 +13,11 @@ The current Python policy accepts at most 100 MiB of encoded local-audio input a | CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | | OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | | librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count exceeds the accepted limit. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | +| yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | ## Residual risk and follow-up -This policy bounds the Python local-audio decode and downstream model/beat-analysis entry points. It does not yet establish whole-product parity for the desktop/Rust intake path, source channel/rate metadata, peak-memory estimates, CPU/GPU budgets, cancellation latency, or all external decoder behaviors. Those remain tracked by issue #781 and must be proven before that issue closes. +This policy now bounds Python decode/model entry, native local-file bootstrap, and YouTube download/bootstrap encoded-byte admission. Remaining #781 work is source channel/rate metadata contracts, decoded-memory estimates, CPU/GPU admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort must stay in place so an unknown-size transfer cannot fill the cache root. ## References @@ -25,3 +26,7 @@ librosa development team. (2025). *librosa.load (librosa 0.11.0)* [Documentation MITRE Corporation. (2026, April 30). *CWE-770: Allocation of resources without limits or throttling (Version 4.20).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/770.html OWASP Foundation. (2025, May). *OWASP Application Security Verification Standard 5.0.0.* https://github.com/OWASP/ASVS/tree/v5.0.0_release/5.0 + +yt-dlp contributors. (2026). *FileDownloader parameters (`max_filesize`)* [Source documentation]. https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/downloader/common.py + +yt-dlp contributors. (2026). *YoutubeDL `progress_hooks`* [Source documentation]. https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py diff --git a/docs/security/app-security.md b/docs/security/app-security.md index 8f7d73dd1..493854056 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -149,6 +149,8 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Validate scheme, host, path, and query before any fetch or handoff. - Do not widen URL intake into a generic remote downloader. - Sanitize remote metadata before display. +- Apply the same canonical 100 MiB encoded-byte ceiling during YouTube download as local-file intake. Abort with yt-dlp `max_filesize` and a progress hook; do not keep a divergent post-download-only 50 MB limit that lets a large transfer fill the cache root first. +- Revalidate the filesystem-observed downloaded length before storing bootstrap state. Treat announced `filesize` / `filesize_approx` as a pre-download hint only. ### Subprocesses and native tools From d1a75c9c3c1b39212a8f684733c2ea6000e9f1dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:06:19 +0900 Subject: [PATCH 043/448] test(audio): cover fail-closed resource admission branches --- ...io_resource_policy_coverage_regressions.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py diff --git a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py new file mode 100644 index 000000000..5bfbb0909 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py @@ -0,0 +1,71 @@ +"""Coverage regressions for fail-closed audio resource admission branches.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from bandscope_analysis.audio_resource_policy import AudioResourcePolicy +from bandscope_analysis.separation.audio_separator import ( + AudioSeparationConfig, + AudioStemSeparator, +) + + +def test_policy_rejects_boolean_duration_configuration() -> None: + """A Boolean duration must not be coerced into a one-second resource budget.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(max_duration_seconds=True) + + +def test_policy_rejects_less_than_one_decoded_sample_budget() -> None: + """A positive duration that represents less than one sample must fail closed.""" + with pytest.raises(ValueError, match="audio resource policy"): + AudioResourcePolicy(target_sample_rate=1, max_duration_seconds=0.5) + + +def test_separator_rejects_empty_internal_loader_result_before_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unexpected empty loader result must not reach Demucs inference.""" + audio_path = tmp_path / "unexpected-empty.wav" + audio_path.write_bytes(b"not-empty") + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + monkeypatch.setattr( + separator, + "_load_audio", + lambda _path: (np.array([], dtype=np.float32), 8_000), + ) + + def fail_if_model_runs(_audio: np.ndarray, _sample_rate: int) -> dict[str, np.ndarray]: + raise AssertionError("empty decoded audio must be rejected before model inference") + + monkeypatch.setattr(separator, "_separate_signal", fail_if_model_runs) + + with pytest.raises(ValueError, match="Stem separation decode failed"): + separator.separate(audio_path) + + +def test_separator_rejects_zero_byte_file_before_decoder( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A zero-byte selected source must fail before the decoder is invoked.""" + audio_path = tmp_path / "empty.wav" + audio_path.write_bytes(b"") + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + + def fail_if_decoder_runs(*_args: object, **_kwargs: object) -> tuple[np.ndarray, int]: + raise AssertionError("zero-byte input must be rejected before decoder invocation") + + monkeypatch.setattr("bandscope_analysis.separation.audio_separator.librosa.load", fail_if_decoder_runs) + + with pytest.raises(ValueError, match="Stem separation decode failed"): + separator.separate(audio_path) From 5e8fa77f6ac1e38a68518285961da5056f22c242 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:26:08 +0000 Subject: [PATCH 044/448] fix(audio): delete owned YouTube partials on in-flight abort Aborting at the 100 MiB ceiling still left .part, .ytdl, and -Frag* siblings in the import cache. Delete only paths that stay inside that import directory so a rejected transfer cannot accumulate cache bytes. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/doctoring/audio-resource-policy.md | 2 +- docs/security/app-security.md | 2 +- .../src/bandscope_analysis/youtube.py | 102 +++++++++++++- .../analysis-engine/tests/test_youtube.py | 127 ++++++++++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a3d09d5..b78cb4201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixed - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. -- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. +- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. ## [0.1.3] - 2026-04-29 diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index dc45b17c0..f13dad669 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -17,7 +17,7 @@ The current Python policy accepts at most 100 MiB of encoded local-audio input a ## Residual risk and follow-up -This policy now bounds Python decode/model entry, native local-file bootstrap, and YouTube download/bootstrap encoded-byte admission. Remaining #781 work is source channel/rate metadata contracts, decoded-memory estimates, CPU/GPU admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort must stay in place so an unknown-size transfer cannot fill the cache root. +This policy now bounds Python decode/model entry, native local-file bootstrap, and YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. Remaining #781 work is source channel/rate metadata contracts, decoded-memory estimates, CPU/GPU admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. ## References diff --git a/docs/security/app-security.md b/docs/security/app-security.md index 493854056..0bc942986 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -149,7 +149,7 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Validate scheme, host, path, and query before any fetch or handoff. - Do not widen URL intake into a generic remote downloader. - Sanitize remote metadata before display. -- Apply the same canonical 100 MiB encoded-byte ceiling during YouTube download as local-file intake. Abort with yt-dlp `max_filesize` and a progress hook; do not keep a divergent post-download-only 50 MB limit that lets a large transfer fill the cache root first. +- Apply the same canonical 100 MiB encoded-byte ceiling during YouTube download as local-file intake. Abort with yt-dlp `max_filesize` and a progress hook, then delete owned `.part` / `.ytdl` / `-Frag*` siblings that stay inside that import directory. Do not keep a divergent post-download-only 50 MB limit that lets a large transfer fill the cache root first. - Revalidate the filesystem-observed downloaded length before storing bootstrap state. Treat announced `filesize` / `filesize_approx` as a pre-download hint only. ### Subprocesses and native tools diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index 7f0d11e10..aaf8fff0a 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -12,6 +12,9 @@ ceiling reject the import before ``download=True``. - The opened-file size is revalidated with ``AudioResourcePolicy`` after download; oversize artifacts are deleted. + - In-flight abort deletes owned ``tmpfilename`` / ``filename`` siblings + (``.part``, ``.ytdl``, ``-Frag*``) that stay inside this import's + ``out_dir``. Paths that escape the directory are ignored. - Validation errors are payload-free and never include source paths, URLs, cookies, or audio content. """ @@ -153,11 +156,84 @@ def _reject_announced_oversize(info: dict[str, Any]) -> Dict[str, Any] | None: return None -def _abort_over_budget_download(status: dict[str, Any]) -> None: +def _owned_file_path(path: object, out_dir: str) -> str | None: + """Return a real path only when it stays inside this import's output directory. + + Args: + path: Candidate filesystem path from yt-dlp status or sibling lookup. + out_dir: Directory passed to this import call. + + Returns: + The resolved file path, or ``None`` when the value is unsafe or foreign. + """ + if not isinstance(path, str) or path == "": + return None + try: + resolved = os.path.realpath(path) + root = os.path.realpath(out_dir) + except OSError: + return None + if resolved == root or not resolved.startswith(root + os.sep): + return None + return resolved + + +def _remove_owned_file(path: object, out_dir: str) -> None: + """Delete one owned regular file, ignoring missing-path races. + + Args: + path: Candidate path that must resolve inside ``out_dir``. + out_dir: Directory passed to this import call. + """ + owned = _owned_file_path(path, out_dir) + if owned is None: + return + try: + if os.path.isfile(owned): + os.remove(owned) + except OSError: + return + + +def _remove_download_artifacts(status: dict[str, Any], out_dir: str) -> None: + """Delete the current download's partial, fragment, and control files. + + Args: + status: yt-dlp progress-hook payload that may name ``tmpfilename`` + and ``filename``. + out_dir: Directory passed to this import call. + """ + stems: set[str] = set() + for key in ("tmpfilename", "filename"): + owned = _owned_file_path(status.get(key), out_dir) + if owned is None: + continue + _remove_owned_file(owned, out_dir) + name = os.path.basename(owned) + if name.endswith(".part"): + name = name[: -len(".part")] + stems.add(name) + if not stems: + return + try: + entries = os.listdir(out_dir) + except OSError: + return + for entry in entries: + matches_stem = any( + entry == stem or entry.startswith(f"{stem}.") or entry.startswith(f"{stem}-") + for stem in stems + ) + if matches_stem: + _remove_owned_file(os.path.join(out_dir, entry), out_dir) + + +def _abort_over_budget_download(status: dict[str, Any], out_dir: str) -> None: """Abort an in-flight download once encoded bytes exceed the policy ceiling. Args: status: yt-dlp progress-hook payload. Unknown statuses are ignored. + out_dir: Directory passed to this import call, used to delete partials. """ if status.get("status") not in {"downloading", "finished"}: return @@ -166,9 +242,31 @@ def _abort_over_budget_download(status: dict[str, Any]) -> None: if isinstance(candidate, bool) or not isinstance(candidate, int): continue if candidate > DEFAULT_MAX_ENCODED_FILE_BYTES: + _remove_download_artifacts(status, out_dir) raise YoutubeResourceLimitError("size_exceeded", YOUTUBE_SIZE_EXCEEDED_MESSAGE) +def _make_abort_hook(out_dir: str) -> Any: + """Bind the in-flight abort hook to one import output directory. + + Args: + out_dir: Directory passed to this import call. + + Returns: + A yt-dlp progress hook that aborts and deletes owned partials. + """ + + def _bound_abort_over_budget_download(status: dict[str, Any]) -> None: + """Abort and delete owned partials for this import directory. + + Args: + status: yt-dlp progress-hook payload. + """ + _abort_over_budget_download(status, out_dir) + + return _bound_abort_over_budget_download + + def _handle_download_error(e: yt_dlp.utils.DownloadError) -> Dict[str, Any]: """Map yt-dlp DownloadError to the public YouTube import error response.""" msg = str(e).lower() @@ -230,7 +328,7 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: "postprocessors": [{"key": "FFmpegExtractAudio"}], "geo_bypass": False, "max_filesize": DEFAULT_MAX_ENCODED_FILE_BYTES, - "progress_hooks": [_abort_over_budget_download], + "progress_hooks": [_make_abort_hook(out_dir)], } try: diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 7ed39e00c..35b0d6689 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -2,6 +2,7 @@ import importlib import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -11,6 +12,9 @@ from bandscope_analysis.youtube import ( MAX_YOUTUBE_URL_LENGTH, YOUTUBE_SIZE_EXCEEDED_MESSAGE, + _owned_file_path, + _remove_download_artifacts, + _remove_owned_file, download_youtube_audio, validate_url, ) @@ -461,6 +465,129 @@ def extract_info(_url: str, download: bool = False) -> dict[str, object]: assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_youtube_audio_progress_hook_deletes_partial_artifacts( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """In-flight abort must delete written partials so they cannot fill the cache.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + outsider = tmp_path / "unrelated-youtube-partial.part" + partial = out_dir / "abc123DEF45.m4a.part" + fragment = out_dir / "abc123DEF45.m4a-Frag1" + control = out_dir / "abc123DEF45.m4a.ytdl" + keep = out_dir / "keep-me.txt" + partial.write_bytes(b"partial-cache-bytes") + fragment.write_bytes(b"hls-fragment-bytes") + control.write_bytes(b"ytdl-control-bytes") + keep.write_bytes(b"unrelated-cache-note") + outsider.write_bytes(b"must-not-delete") + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + + def extract_info(_url: str, download: bool = False) -> dict[str, object]: + """Abort after yt-dlp has already written the current block to disk.""" + if download: + hook = mock_ydl_class.call_args[0][0]["progress_hooks"][0] + hook( + { + "status": "downloading", + "downloaded_bytes": DEFAULT_MAX_ENCODED_FILE_BYTES + 1, + "tmpfilename": str(partial), + "filename": str(out_dir / "abc123DEF45.m4a"), + } + ) + return {"id": "abc123DEF45", "duration": 60} + + mock_ydl.extract_info.side_effect = extract_info + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result["ok"] is False + assert result["error"]["code"] == "size_exceeded" + assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE + assert not partial.exists() + assert not fragment.exists() + assert not control.exists() + assert keep.exists() + assert outsider.exists() + + +def test_owned_file_path_rejects_empty_foreign_and_unresolvable_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Abort cleanup must not follow empty, escaped, or unresolvable paths.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + escaped = tmp_path / "outside.part" + escaped.write_bytes(b"keep") + + assert _owned_file_path(None, str(out_dir)) is None + assert _owned_file_path("", str(out_dir)) is None + assert _owned_file_path(str(out_dir), str(out_dir)) is None + assert _owned_file_path(str(escaped), str(out_dir)) is None + + def boom(_path: str) -> str: + """Simulate a filesystem error while resolving a candidate path.""" + raise OSError("realpath failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.path.realpath", boom) + assert _owned_file_path(str(out_dir / "clip.part"), str(out_dir)) is None + + +def test_remove_owned_file_ignores_missing_directories_and_remove_races( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Owned cleanup skips non-files and treats remove races as already gone.""" + out_dir = tmp_path / "import-cache" + nested = out_dir / "nested-dir" + nested.mkdir(parents=True) + _remove_owned_file(None, str(out_dir)) + _remove_owned_file(str(nested), str(out_dir)) + assert nested.is_dir() + + target = out_dir / "clip.part" + target.write_bytes(b"partial") + + def boom(_path: str) -> None: + """Simulate a disappearing file during abort cleanup.""" + raise OSError("remove failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.remove", boom) + _remove_owned_file(str(target), str(out_dir)) + assert target.exists() + + +def test_remove_download_artifacts_skips_empty_status_and_unlistable_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Artifact sweep no-ops when yt-dlp omitted paths or the cache vanished.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + leftover = out_dir / "other-file.txt" + leftover.write_bytes(b"keep") + _remove_download_artifacts({"tmpfilename": None, "filename": 12}, str(out_dir)) + assert leftover.exists() + + partial = out_dir / "abc123DEF45.m4a.part" + partial.write_bytes(b"partial") + + def boom(_path: str) -> list[str]: + """Simulate the import cache disappearing after the first delete.""" + raise OSError("listdir failed") + + monkeypatch.setattr("bandscope_analysis.youtube.os.listdir", boom) + _remove_download_artifacts({"tmpfilename": str(partial)}, str(out_dir)) + assert leftover.exists() + + @patch("bandscope_analysis.youtube.os.path.getsize") @patch("bandscope_analysis.youtube.os.path.exists") @patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") From 6df13aebc695dbac1001e98c3fc0ad14ec196c13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:15:06 +0900 Subject: [PATCH 045/448] style(audio): wrap zero-byte decoder regression --- .../tests/test_audio_resource_policy_coverage_regressions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py index 5bfbb0909..f45e5d454 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py +++ b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py @@ -65,7 +65,10 @@ def test_separator_rejects_zero_byte_file_before_decoder( def fail_if_decoder_runs(*_args: object, **_kwargs: object) -> tuple[np.ndarray, int]: raise AssertionError("zero-byte input must be rejected before decoder invocation") - monkeypatch.setattr("bandscope_analysis.separation.audio_separator.librosa.load", fail_if_decoder_runs) + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.librosa.load", + fail_if_decoder_runs, + ) with pytest.raises(ValueError, match="Stem separation decode failed"): separator.separate(audio_path) From 6d3b9b28e68cd9a2efe73200a385ace87708fb2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:18:45 +0900 Subject: [PATCH 046/448] test(audio): reject malformed YouTube duration metadata --- .../tests/test_youtube_duration_contract.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 services/analysis-engine/tests/test_youtube_duration_contract.py diff --git a/services/analysis-engine/tests/test_youtube_duration_contract.py b/services/analysis-engine/tests/test_youtube_duration_contract.py new file mode 100644 index 000000000..d5d9c12b7 --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_duration_contract.py @@ -0,0 +1,38 @@ +"""Fail-closed YouTube duration metadata admission contract.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from bandscope_analysis.youtube import download_youtube_audio + + +@pytest.mark.parametrize("duration", [True, 0, -1, float("nan"), float("inf"), "60"]) +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_youtube_rejects_malformed_announced_duration_before_download( + mock_ydl_class: MagicMock, + duration: object, +) -> None: + """Malformed known-duration metadata must not authorize a media download.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "duration": duration, + } + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "download_error", + "message": "YouTube import failed. Please use a local audio file instead.", + }, + } + mock_ydl.extract_info.assert_called_once_with( + "https://youtube.com/watch?v=abc123DEF45", + download=False, + ) From 86f72725b0a2f3e47dbe6599e33240893f73eb3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:19:32 +0900 Subject: [PATCH 047/448] fix(audio): reject malformed YouTube duration metadata --- .../src/bandscope_analysis/youtube.py | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index aaf8fff0a..1a70281db 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -8,6 +8,8 @@ audio. yt-dlp ``max_filesize`` and a progress hook abort in-flight transfers so a multi-gigabyte download cannot fill the cache root before the post-download check runs. + - Announced duration must be a finite positive non-Boolean number when + present; malformed known-duration metadata fails closed before download. - Announced ``filesize`` / ``filesize_approx`` values over the policy ceiling reject the import before ``download=True``. - The opened-file size is revalidated with ``AudioResourcePolicy`` after @@ -123,6 +125,46 @@ def _size_exceeded_result() -> Dict[str, Any]: } +def _download_error_result() -> Dict[str, Any]: + """Return the payload-safe generic import failure result.""" + return { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + + +def _reject_invalid_or_oversize_duration(info: dict[str, Any]) -> Dict[str, Any] | None: + """Validate announced duration before authorizing download work. + + Args: + info: Metadata dictionary from ``extract_info(..., download=False)``. + + Returns: + A payload-safe failure for malformed/over-budget known duration, or + ``None`` when duration is absent or valid and within policy. + """ + duration = info.get("duration") + if duration is None: + return None + if isinstance(duration, bool) or not isinstance(duration, int | float): + return _download_error_result() + try: + duration_seconds = float(duration) + except (OverflowError, ValueError): + return _download_error_result() + if not math.isfinite(duration_seconds) or duration_seconds <= 0.0: + return _download_error_result() + if duration_seconds > DEFAULT_MAX_DURATION_SECONDS: + return { + "ok": False, + "error": { + "code": "duration_exceeded", + "message": "Video exceeds the 15-minute limit.", + }, + } + return None + + def _announced_size_exceeds_policy(announced: object) -> bool: """Return whether yt-dlp metadata already reports an over-budget file. @@ -336,15 +378,9 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: info = ydl.extract_info(url, download=False) if info is None: raise Exception("Failed to extract info") - duration = info.get("duration") - if duration is not None and duration > DEFAULT_MAX_DURATION_SECONDS: - return { - "ok": False, - "error": { - "code": "duration_exceeded", - "message": "Video exceeds the 15-minute limit.", - }, - } + duration_rejection = _reject_invalid_or_oversize_duration(info) + if duration_rejection is not None: + return duration_rejection announced_rejection = _reject_announced_oversize(info) if announced_rejection is not None: return announced_rejection @@ -387,10 +423,7 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: except yt_dlp.utils.DownloadError as e: return _handle_download_error(e) except Exception: - return { - "ok": False, - "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, - } + return _download_error_result() def main() -> None: From bc0fc9806c0488411948f8475054941fecfcb327 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:20:01 +0900 Subject: [PATCH 048/448] docs(changelog): record YouTube duration metadata guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78cb4201..7d0ecafde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, and negative duration evidence can no longer authorize a media download through Python numeric coercion or unordered comparisons. - Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. From ec64aa7805bbf2b0f8692b09360ab29dc388d6dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:15:41 +0900 Subject: [PATCH 049/448] test(audio): bound decoded buffer memory --- .../tests/test_audio_resource_policy.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index 75e2871df..9789b8f02 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -19,6 +19,7 @@ def test_default_policy_has_stable_version_and_rehearsal_budget() -> None: assert DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate == 44_100 assert DEFAULT_AUDIO_RESOURCE_POLICY.max_duration_seconds == 15 * 60 assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_samples == 44_100 * 15 * 60 + assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes == 44_100 * 15 * 60 * 8 @pytest.mark.parametrize("file_size", [True, -1, 0, 101]) @@ -60,6 +61,31 @@ def test_decoded_audio_fails_closed_outside_policy( policy.validate_decoded_audio(audio, sample_rate) +def test_decoded_audio_rejects_buffer_above_memory_budget() -> None: + """A decoder cannot hide excessive memory behind an allowed sample count.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=16, + ) + audio = np.zeros(4, dtype=np.float64) + + with pytest.raises(ValueError, match="audio resource policy"): + policy.validate_decoded_audio(audio, 8) + + +def test_decoded_audio_accepts_exact_memory_boundary() -> None: + """A finite canonical buffer exactly at the memory ceiling is accepted.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=32, + ) + audio = np.zeros(8, dtype=np.float32) + + assert policy.validate_decoded_audio(audio, 8) is audio + + def test_decoded_audio_accepts_exact_sample_boundary() -> None: """A finite mono artifact exactly at the decoded-sample ceiling is accepted.""" policy = AudioResourcePolicy(target_sample_rate=8_000, max_duration_seconds=1.0) @@ -77,6 +103,8 @@ def test_decoded_audio_accepts_exact_sample_boundary() -> None: {"target_sample_rate": 0}, {"max_duration_seconds": 0.0}, {"max_duration_seconds": float("inf")}, + {"max_decoded_audio_bytes": 0}, + {"max_decoded_audio_bytes": True}, ], ) def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> None: @@ -91,6 +119,7 @@ def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> {"target_sample_rate": 10**400, "max_duration_seconds": 1.0}, {"target_sample_rate": 1, "max_duration_seconds": 10**400}, {"max_encoded_file_bytes": 10**400}, + {"max_decoded_audio_bytes": 10**400}, ], ) def test_policy_configuration_fails_closed_on_unrepresentable_limits( From 104573a9baf5387e99f7810dc090a90e6591d2d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:16:29 +0900 Subject: [PATCH 050/448] fix(audio): enforce decoded memory budget --- .../audio_resource_policy.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 1fae11c4f..65d08c0b5 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -11,7 +11,7 @@ opened file descriptor can provide an authoritative size. - Decoded audio is revalidated because container metadata and decoder behavior are untrusted; accepted artifacts are finite, mono, floating-point, at the - configured sample rate, and within the configured decoded-sample budget. + configured sample rate, and within configured sample and memory budgets. - Decoders receive a one-sample-over-budget probe duration so a longer source is rejected instead of being silently truncated to the accepted duration. - Policy arithmetic rejects unrepresentable limits before float/sample-count @@ -34,6 +34,9 @@ DEFAULT_TARGET_SAMPLE_RATE = 44_100 DEFAULT_MAX_ENCODED_FILE_BYTES = 100 * 1024 * 1024 DEFAULT_MAX_DURATION_SECONDS = 15 * 60 +DEFAULT_MAX_DECODED_AUDIO_BYTES = ( + DEFAULT_TARGET_SAMPLE_RATE * DEFAULT_MAX_DURATION_SECONDS * np.dtype(np.float64).itemsize +) _POLICY_ERROR = "Audio input violates the audio resource policy." @@ -47,11 +50,14 @@ class AudioResourcePolicy: artifact. max_duration_seconds: Maximum decoded duration represented as a sample ceiling at ``target_sample_rate``. + max_decoded_audio_bytes: Maximum in-memory byte size of the canonical + decoded mono NumPy buffer. """ max_encoded_file_bytes: int = DEFAULT_MAX_ENCODED_FILE_BYTES target_sample_rate: int = DEFAULT_TARGET_SAMPLE_RATE max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) + max_decoded_audio_bytes: int = DEFAULT_MAX_DECODED_AUDIO_BYTES def __post_init__(self) -> None: """Reject invalid policy configuration before it can weaken admission.""" @@ -73,6 +79,13 @@ def __post_init__(self) -> None: self.max_duration_seconds, int | float ): raise ValueError(_POLICY_ERROR) + if ( + isinstance(self.max_decoded_audio_bytes, bool) + or not isinstance(self.max_decoded_audio_bytes, int) + or self.max_decoded_audio_bytes <= 0 + or self.max_decoded_audio_bytes > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) try: duration_seconds = float(self.max_duration_seconds) except (OverflowError, ValueError): @@ -133,8 +146,8 @@ def validate_decoded_audio( The original validated NumPy floating-point array without copying it. Raises: - ValueError: If dtype, shape, sample rate, sample count, or finiteness - does not satisfy this policy. + ValueError: If dtype, shape, sample rate, sample count, memory use, + or finiteness does not satisfy this policy. """ if ( not isinstance(audio, np.ndarray) @@ -149,7 +162,11 @@ def validate_decoded_audio( or sample_rate != self.target_sample_rate ): raise ValueError(_POLICY_ERROR) - if audio.size > self.max_decoded_samples or not np.isfinite(audio).all(): + if ( + audio.size > self.max_decoded_samples + or audio.nbytes > self.max_decoded_audio_bytes + or not np.isfinite(audio).all() + ): raise ValueError(_POLICY_ERROR) return cast(NDArray[np.floating[Any]], audio) @@ -160,6 +177,7 @@ def validate_decoded_audio( "AUDIO_RESOURCE_POLICY_VERSION", "AudioResourcePolicy", "DEFAULT_AUDIO_RESOURCE_POLICY", + "DEFAULT_MAX_DECODED_AUDIO_BYTES", "DEFAULT_MAX_DURATION_SECONDS", "DEFAULT_MAX_ENCODED_FILE_BYTES", "DEFAULT_TARGET_SAMPLE_RATE", From c6d9368d95a39a23ed6dd7a057c7dc1d03af9584 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:17:23 +0900 Subject: [PATCH 051/448] docs(audio): record decoded memory admission --- docs/doctoring/audio-resource-policy.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index f13dad669..f942896f9 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -4,20 +4,20 @@ This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. -The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and no longer than the accepted sample budget before beat tracking or Demucs inference. Policy construction also rejects byte, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and within both the accepted sample count and decoded-buffer byte budget before beat tracking or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. ## Evidence-to-control mapping | Evidence | BandScope control | | --- | --- | -| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | +| CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, decoded mono-buffer bytes, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | | OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | -| librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count exceeds the accepted limit. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | +| librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count or in-memory byte size exceeds the accepted limits. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | | yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | ## Residual risk and follow-up -This policy now bounds Python decode/model entry, native local-file bootstrap, and YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. Remaining #781 work is source channel/rate metadata contracts, decoded-memory estimates, CPU/GPU admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. +This policy now bounds Python decode/model entry by decoded sample count and decoded mono-buffer memory, and bounds native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is source channel/rate metadata contracts, explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. ## References From f60eb17c0bc550ebde67c930c538f3a6da2c8d6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:17:58 +0900 Subject: [PATCH 052/448] docs(audio): record decoded memory budget --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0ecafde..4389b03d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. - Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, and negative duration evidence can no longer authorize a media download through Python numeric coercion or unordered comparisons. - Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. From aeb42daf9bea22261a98ac0205fbdeb7220b13af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:46:43 +0900 Subject: [PATCH 053/448] test(youtube): cover fail-closed duration conversion --- .../tests/test_youtube_duration_contract.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_youtube_duration_contract.py b/services/analysis-engine/tests/test_youtube_duration_contract.py index d5d9c12b7..bacf04a6a 100644 --- a/services/analysis-engine/tests/test_youtube_duration_contract.py +++ b/services/analysis-engine/tests/test_youtube_duration_contract.py @@ -9,7 +9,35 @@ from bandscope_analysis.youtube import download_youtube_audio -@pytest.mark.parametrize("duration", [True, 0, -1, float("nan"), float("inf"), "60"]) +class _ValueErrorFloat(float): + """Numeric metadata whose explicit float conversion is malformed.""" + + def __float__(self) -> float: + """Reject conversion with the malformed-value failure shape.""" + raise ValueError("malformed duration") + + +class _OverflowFloat(float): + """Numeric metadata whose explicit float conversion overflows.""" + + def __float__(self) -> float: + """Reject conversion with the overflow failure shape.""" + raise OverflowError("duration overflow") + + +@pytest.mark.parametrize( + "duration", + [ + True, + 0, + -1, + float("nan"), + float("inf"), + "60", + _ValueErrorFloat(1.0), + _OverflowFloat(1.0), + ], +) @patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") def test_youtube_rejects_malformed_announced_duration_before_download( mock_ydl_class: MagicMock, From f35f1d45aec8a86b10d9776cf51556720dc879ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:37:41 +0900 Subject: [PATCH 054/448] test(youtube): avoid exceptional float subclasses --- .../tests/test_youtube_duration_contract.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/tests/test_youtube_duration_contract.py b/services/analysis-engine/tests/test_youtube_duration_contract.py index bacf04a6a..0f5c065c1 100644 --- a/services/analysis-engine/tests/test_youtube_duration_contract.py +++ b/services/analysis-engine/tests/test_youtube_duration_contract.py @@ -9,16 +9,16 @@ from bandscope_analysis.youtube import download_youtube_audio -class _ValueErrorFloat(float): - """Numeric metadata whose explicit float conversion is malformed.""" +class _ValueErrorDuration: + """Metadata whose explicit float conversion is malformed.""" def __float__(self) -> float: """Reject conversion with the malformed-value failure shape.""" raise ValueError("malformed duration") -class _OverflowFloat(float): - """Numeric metadata whose explicit float conversion overflows.""" +class _OverflowDuration: + """Metadata whose explicit float conversion overflows.""" def __float__(self) -> float: """Reject conversion with the overflow failure shape.""" @@ -34,8 +34,8 @@ def __float__(self) -> float: float("nan"), float("inf"), "60", - _ValueErrorFloat(1.0), - _OverflowFloat(1.0), + _ValueErrorDuration(), + _OverflowDuration(), ], ) @patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") From f5f0c3d9c1ff9390682f0b3c3801efc2cc6acf19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:46:43 +0900 Subject: [PATCH 055/448] test(youtube): reject noncanonical numeric metadata --- .../tests/test_youtube_duration_contract.py | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/services/analysis-engine/tests/test_youtube_duration_contract.py b/services/analysis-engine/tests/test_youtube_duration_contract.py index 0f5c065c1..0cb168787 100644 --- a/services/analysis-engine/tests/test_youtube_duration_contract.py +++ b/services/analysis-engine/tests/test_youtube_duration_contract.py @@ -9,20 +9,8 @@ from bandscope_analysis.youtube import download_youtube_audio -class _ValueErrorDuration: - """Metadata whose explicit float conversion is malformed.""" - - def __float__(self) -> float: - """Reject conversion with the malformed-value failure shape.""" - raise ValueError("malformed duration") - - -class _OverflowDuration: - """Metadata whose explicit float conversion overflows.""" - - def __float__(self) -> float: - """Reject conversion with the overflow failure shape.""" - raise OverflowError("duration overflow") +class _NonCanonicalFloat(float): + """Numeric subtype that must not cross the untrusted metadata boundary.""" @pytest.mark.parametrize( @@ -34,8 +22,8 @@ def __float__(self) -> float: float("nan"), float("inf"), "60", - _ValueErrorDuration(), - _OverflowDuration(), + object(), + _NonCanonicalFloat(60.0), ], ) @patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") From f4cee9e6caff26c1d7c48056cef32e5d5c42ab61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:49:52 +0900 Subject: [PATCH 056/448] fix(youtube): reject non-canonical duration numerics --- services/analysis-engine/src/bandscope_analysis/youtube.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index 1a70281db..beb677a3c 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -146,7 +146,7 @@ def _reject_invalid_or_oversize_duration(info: dict[str, Any]) -> Dict[str, Any] duration = info.get("duration") if duration is None: return None - if isinstance(duration, bool) or not isinstance(duration, int | float): + if type(duration) not in (int, float): return _download_error_result() try: duration_seconds = float(duration) From 69cdf8be67d4ae71b821b3c207fb10be3ab9cb80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:50:56 +0900 Subject: [PATCH 057/448] docs(changelog): record strict duration metadata type gate --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4389b03d6..7969e2402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. - Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. -- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, and negative duration evidence can no longer authorize a media download through Python numeric coercion or unordered comparisons. +- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. - Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. From 3ea49d3a6073b46159342cdec56d6794778ae214 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:51:53 +0900 Subject: [PATCH 058/448] fix(youtube): remove unreachable duration conversion branch --- services/analysis-engine/src/bandscope_analysis/youtube.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index beb677a3c..af0a721d0 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -148,10 +148,7 @@ def _reject_invalid_or_oversize_duration(info: dict[str, Any]) -> Dict[str, Any] return None if type(duration) not in (int, float): return _download_error_result() - try: - duration_seconds = float(duration) - except (OverflowError, ValueError): - return _download_error_result() + duration_seconds = float(duration) if not math.isfinite(duration_seconds) or duration_seconds <= 0.0: return _download_error_result() if duration_seconds > DEFAULT_MAX_DURATION_SECONDS: From 97490c7572e109466957689dab3d05a6c90e8624 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:21:35 +0900 Subject: [PATCH 059/448] test(youtube): require post-download duration revalidation --- ...outube_downloaded_duration_revalidation.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py diff --git a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py new file mode 100644 index 000000000..4c8f7f0db --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py @@ -0,0 +1,38 @@ +"""Post-download YouTube duration revalidation regressions.""" + +from unittest.mock import MagicMock, patch + +from bandscope_analysis.youtube import download_youtube_audio + + +@patch("bandscope_analysis.youtube.os.path.getsize") +@patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.os.remove") +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_youtube_revalidates_downloaded_duration_before_returning_success( + mock_ydl_class: MagicMock, + mock_remove: MagicMock, + mock_exists: MagicMock, + mock_getsize: MagicMock, +) -> None: + """Changed download metadata must not bypass the 15-minute admission limit.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.side_effect = [ + {"id": "abc123DEF45", "duration": 60}, + {"id": "abc123DEF45", "title": "Changed metadata", "duration": 16 * 60}, + ] + mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_exists.return_value = True + mock_getsize.return_value = 10 * 1024 * 1024 + + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + + assert result == { + "ok": False, + "error": { + "code": "duration_exceeded", + "message": "Video exceeds the 15-minute limit.", + }, + } + mock_remove.assert_called_once_with("/tmp/abc123DEF45.m4a") From d58c24eb4377e564c99d1c5d3f959c76b93b2253 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:22:18 +0900 Subject: [PATCH 060/448] test(youtube): model owned cleanup on duration drift --- .../tests/test_youtube_downloaded_duration_revalidation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py index 4c8f7f0db..2999a3308 100644 --- a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py +++ b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py @@ -5,15 +5,15 @@ from bandscope_analysis.youtube import download_youtube_audio -@patch("bandscope_analysis.youtube.os.path.getsize") @patch("bandscope_analysis.youtube.os.path.exists") +@patch("bandscope_analysis.youtube.os.path.isfile") @patch("bandscope_analysis.youtube.os.remove") @patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") def test_youtube_revalidates_downloaded_duration_before_returning_success( mock_ydl_class: MagicMock, mock_remove: MagicMock, + mock_isfile: MagicMock, mock_exists: MagicMock, - mock_getsize: MagicMock, ) -> None: """Changed download metadata must not bypass the 15-minute admission limit.""" mock_ydl = MagicMock() @@ -24,7 +24,7 @@ def test_youtube_revalidates_downloaded_duration_before_returning_success( ] mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" mock_exists.return_value = True - mock_getsize.return_value = 10 * 1024 * 1024 + mock_isfile.return_value = True result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") From d3e27929d794dd6333ca5458ecfa3ed705f3af52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:23:01 +0900 Subject: [PATCH 061/448] fix(youtube): revalidate duration after download --- .../analysis-engine/src/bandscope_analysis/youtube.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index af0a721d0..4faa44557 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -10,6 +10,8 @@ the post-download check runs. - Announced duration must be a finite positive non-Boolean number when present; malformed known-duration metadata fails closed before download. + Download-result duration is revalidated before success so changed + metadata cannot bypass the same 15-minute admission boundary. - Announced ``filesize`` / ``filesize_approx`` values over the policy ceiling reject the import before ``download=True``. - The opened-file size is revalidated with ``AudioResourcePolicy`` after @@ -137,7 +139,7 @@ def _reject_invalid_or_oversize_duration(info: dict[str, Any]) -> Dict[str, Any] """Validate announced duration before authorizing download work. Args: - info: Metadata dictionary from ``extract_info(..., download=False)``. + info: Metadata dictionary from yt-dlp extraction. Returns: A payload-safe failure for malformed/over-budget known duration, or @@ -386,7 +388,6 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: if info is None: raise Exception("Failed to extract info") actual_filepath = ydl.prepare_filename(info) - actual_filepath = _find_downloaded_file(actual_filepath) if actual_filepath is None: @@ -398,6 +399,11 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } + duration_rejection = _reject_invalid_or_oversize_duration(info) + if duration_rejection is not None: + _remove_owned_file(actual_filepath, out_dir) + return duration_rejection + try: DEFAULT_AUDIO_RESOURCE_POLICY.validate_encoded_file_bytes( os.path.getsize(actual_filepath) From 1c85058e9824505d20c375e73cbc925be6bbf37f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:19:39 +0900 Subject: [PATCH 062/448] test(privacy): fail on temporal path disclosure --- .../tests/test_temporal_error_privacy.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 services/analysis-engine/tests/test_temporal_error_privacy.py diff --git a/services/analysis-engine/tests/test_temporal_error_privacy.py b/services/analysis-engine/tests/test_temporal_error_privacy.py new file mode 100644 index 000000000..bb849c791 --- /dev/null +++ b/services/analysis-engine/tests/test_temporal_error_privacy.py @@ -0,0 +1,55 @@ +"""Privacy regressions for temporal-analysis failure diagnostics.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from bandscope_analysis.temporal import TemporalAnalyzer + + +def test_missing_temporal_source_does_not_disclose_local_path(tmp_path: Path) -> None: + """Missing-file failures must not echo an absolute customer path to callers.""" + sensitive_path = tmp_path / "private-customer-session" / "unreleased-song.wav" + + with pytest.raises(FileNotFoundError) as exc_info: + TemporalAnalyzer().analyze(sensitive_path) + + message = str(exc_info.value) + assert message == "Audio source is unavailable for temporal analysis." + assert str(sensitive_path) not in message + assert "unreleased-song.wav" not in message + + +def test_decoder_failure_redacts_source_path_and_decoder_payload( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Decoder diagnostics must remain useful without logging customer path/payload data.""" + import librosa + + sensitive_path = tmp_path / "private-customer-session" / "unreleased-song.wav" + sensitive_path.parent.mkdir() + sensitive_path.write_bytes(b"bounded-test-input") + decoder_payload = "decoder exposed /private/customer/token-shaped-audio-name.wav" + + def fail_decode(*args: object, **kwargs: object) -> tuple[object, int]: + raise RuntimeError(decoder_payload) + + monkeypatch.setattr(librosa, "load", fail_decode) + caplog.set_level(logging.INFO, logger="bandscope_analysis.temporal.analyzer") + + with pytest.raises(ValueError) as exc_info: + TemporalAnalyzer().analyze(sensitive_path) + + message = str(exc_info.value) + assert message == "Temporal analysis failed." + assert str(sensitive_path) not in message + assert decoder_payload not in message + assert str(sensitive_path) not in caplog.text + assert "unreleased-song.wav" not in caplog.text + assert decoder_payload not in caplog.text + assert "RuntimeError" in caplog.text From 4a6e2699c781ec7c5f46ff12ed10b22b1c379bc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:20:39 +0900 Subject: [PATCH 063/448] fix(privacy): redact temporal analysis failure diagnostics --- .../bandscope_analysis/temporal/analyzer.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 1584517bd..bbedd53e9 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -33,6 +33,15 @@ (DeprecationWarning, r".*pkg_resources is deprecated.*", r".*librosa.*"), (FutureWarning, r".*Numba.*", r".*numba.*"), ) +_SAFE_TEMPORAL_FAILURE_MESSAGES = frozenset( + { + "Audio file is too large for temporal analysis", + "Audio input violates the audio resource policy.", + "Expected numpy array from librosa.load", + } +) +_MISSING_AUDIO_MESSAGE = "Audio source is unavailable for temporal analysis." +_GENERIC_TEMPORAL_FAILURE_MESSAGE = "Temporal analysis failed." # ponytail: assumes 4/4; upgrade to meter estimation or a madmom DBN if other meters matter. BEATS_PER_BAR = 4 @@ -65,6 +74,14 @@ def _estimate_downbeats( return [float(bt) for i, bt in enumerate(beat_times) if (i - best_phase) % beats_per_bar == 0] +def _safe_temporal_failure_message(error: Exception) -> str: + """Return an allowlisted diagnostic without relaying decoder payload text.""" + message = str(error) + if message in _SAFE_TEMPORAL_FAILURE_MESSAGES: + return message + return _GENERIC_TEMPORAL_FAILURE_MESSAGE + + class TemporalAnalyzer: """Analyze bounded temporal features (BPM and beat grids) from local audio.""" @@ -95,9 +112,9 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: path = Path(audio_path) path_str = str(path) if not path.exists() or not path.is_file(): - raise FileNotFoundError(f"Audio file not found: {path_str}") + raise FileNotFoundError(_MISSING_AUDIO_MESSAGE) - logger.info(f"Loading and decoding audio: {path_str}") + logger.info("Loading and decoding bounded local audio.") try: with path.open("rb") as fileobj: @@ -161,6 +178,6 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: "audio_path": path_str, } - except Exception as e: - logger.error(f"Failed to analyze audio {path_str}: {e}") - raise ValueError(f"Temporal analysis failed: {e}") from e + except Exception as error: + logger.error("Temporal analysis failed (%s).", type(error).__name__) + raise ValueError(_safe_temporal_failure_message(error)) from error From 6d3c38812e3e3c4ff5220c2b27425cf4324fcb63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:21:33 +0900 Subject: [PATCH 064/448] test(privacy): align temporal diagnostics with redaction contract --- services/analysis-engine/tests/test_temporal.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index 6ce90ae1c..b6fdbb017 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -46,9 +46,9 @@ def test_temporal_analyzer_basic(dummy_audio_file: Path) -> None: def test_temporal_analyzer_file_not_found() -> None: - """Test that analyzer raises appropriate error for missing files.""" + """Test that analyzer raises a payload-safe error for missing files.""" analyzer = TemporalAnalyzer() - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): analyzer.analyze("nonexistent_file.wav") @@ -62,7 +62,7 @@ def test_temporal_analyzer_missing_file_does_not_call_decoder( monkeypatch.setattr(librosa, "load", load_mock) analyzer = TemporalAnalyzer() - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): analyzer.analyze("nonexistent_file.wav") load_mock.assert_not_called() @@ -77,7 +77,7 @@ def test_temporal_analyzer_directory_does_not_call_decoder( load_mock = Mock(side_effect=AssertionError("librosa.load should not be called")) monkeypatch.setattr(librosa, "load", load_mock) - with pytest.raises(FileNotFoundError, match="Audio file not found"): + with pytest.raises(FileNotFoundError, match="Audio source is unavailable"): TemporalAnalyzer().analyze(tmp_path) load_mock.assert_not_called() @@ -104,7 +104,7 @@ def test_temporal_analyzer_exception_handling( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Ensure temporal analyzer catches general exceptions and raises ValueError.""" + """Ensure arbitrary decoder exception payloads are not relayed to callers.""" import librosa from bandscope_analysis.temporal.analyzer import TemporalAnalyzer @@ -117,8 +117,9 @@ def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: test_wav = tmp_path / "test.wav" test_wav.write_bytes(b"dummy") - with pytest.raises(ValueError, match="Temporal analysis failed: Mocked general error"): + with pytest.raises(ValueError, match=r"^Temporal analysis failed\.$") as exc_info: TemporalAnalyzer().analyze(test_wav) + assert "Mocked general error" not in str(exc_info.value) def test_temporal_analyzer_rejects_oversized_file(monkeypatch, tmp_path: Path) -> None: From aa0191c03c5e049b2a33a6b76e6c33b0ec2e7c81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:14:13 -0700 Subject: [PATCH 065/448] test(security): reject foreign YouTube download paths --- ...st_youtube_post_download_path_authority.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 services/analysis-engine/tests/test_youtube_post_download_path_authority.py diff --git a/services/analysis-engine/tests/test_youtube_post_download_path_authority.py b/services/analysis-engine/tests/test_youtube_post_download_path_authority.py new file mode 100644 index 000000000..75b23c5eb --- /dev/null +++ b/services/analysis-engine/tests/test_youtube_post_download_path_authority.py @@ -0,0 +1,74 @@ +"""Regression coverage for post-download YouTube path authority. + +The downloader owns only artifacts that resolve beneath the per-import output +directory. Metadata returned by yt-dlp must not turn an arbitrary filesystem path +into a successful import or deletion target. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bandscope_analysis.audio_resource_policy import DEFAULT_MAX_ENCODED_FILE_BYTES +from bandscope_analysis.youtube import YOUTUBE_IMPORT_FAILED_MESSAGE, download_youtube_audio + + +def _configure_download(mock_ydl_class: MagicMock, filepath: Path) -> None: + """Configure yt-dlp to report one completed download at ``filepath``.""" + mock_ydl = MagicMock() + mock_ydl_class.return_value.__enter__.return_value = mock_ydl + mock_ydl.extract_info.return_value = { + "id": "abc123DEF45", + "title": "Authority regression", + "duration": 60, + } + mock_ydl.prepare_filename.return_value = str(filepath) + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_download_rejects_foreign_completed_path( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """A completed path outside this import directory must never become success metadata.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + foreign = tmp_path / "foreign.m4a" + foreign.write_bytes(b"not-owned-by-this-import") + _configure_download(mock_ydl_class, foreign) + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result == { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + assert foreign.read_bytes() == b"not-owned-by-this-import" + + +@patch("bandscope_analysis.youtube.yt_dlp.YoutubeDL") +def test_oversize_foreign_completed_path_is_not_deleted( + mock_ydl_class: MagicMock, + tmp_path: Path, +) -> None: + """Oversize rejection must not delete a path outside this import's authority.""" + out_dir = tmp_path / "import-cache" + out_dir.mkdir() + foreign = tmp_path / "foreign-oversize.m4a" + with foreign.open("wb") as handle: + handle.truncate(DEFAULT_MAX_ENCODED_FILE_BYTES + 1) + _configure_download(mock_ydl_class, foreign) + + result = download_youtube_audio( + "https://youtube.com/watch?v=abc123DEF45", + str(out_dir), + ) + + assert result == { + "ok": False, + "error": {"code": "download_error", "message": YOUTUBE_IMPORT_FAILED_MESSAGE}, + } + assert foreign.exists() + assert foreign.stat().st_size == DEFAULT_MAX_ENCODED_FILE_BYTES + 1 From 48d06cf8150d2d152a6a42ea7542738725c8f8f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:15:14 -0700 Subject: [PATCH 066/448] fix(security): bind completed YouTube path to import cache --- services/analysis-engine/src/bandscope_analysis/youtube.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index 4faa44557..61c6bab0b 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -14,6 +14,8 @@ metadata cannot bypass the same 15-minute admission boundary. - Announced ``filesize`` / ``filesize_approx`` values over the policy ceiling reject the import before ``download=True``. + - The completed download path must resolve beneath this import's ``out_dir`` + before post-download size checks, cleanup, or success metadata can use it. - The opened-file size is revalidated with ``AudioResourcePolicy`` after download; oversize artifacts are deleted. - In-flight abort deletes owned ``tmpfilename`` / ``filename`` siblings @@ -399,6 +401,11 @@ def download_youtube_audio(url: str, out_dir: str) -> Dict[str, Any]: }, } + owned_filepath = _owned_file_path(actual_filepath, out_dir) + if owned_filepath is None: + return _download_error_result() + actual_filepath = owned_filepath + duration_rejection = _reject_invalid_or_oversize_duration(info) if duration_rejection is not None: _remove_owned_file(actual_filepath, out_dir) From 85424c9dbcb7685a1718cc9e151124b1c2bc9fab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:17:59 -0700 Subject: [PATCH 067/448] docs(security): record YouTube completed-path authority --- docs/doctoring/audio-resource-policy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index f942896f9..3957d42a5 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -4,7 +4,7 @@ This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. -The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and within both the accepted sample count and decoded-buffer byte budget before beat tracking or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and within both the accepted sample count and decoded-buffer byte budget before beat tracking or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Before post-download duration/size checks, cleanup, or success metadata can use the yt-dlp result, the completed path is canonicalized and required to remain strictly beneath the current import `out_dir`; a foreign or escaped path fails closed without being deleted. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. ## Evidence-to-control mapping @@ -13,11 +13,11 @@ The current Python policy accepts at most 100 MiB of encoded local-audio input a | CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, decoded mono-buffer bytes, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | | OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | | librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count or in-memory byte size exceeds the accepted limits. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | -| yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | +| yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, validates that the completed path remains inside the per-import output directory, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | ## Residual risk and follow-up -This policy now bounds Python decode/model entry by decoded sample count and decoded mono-buffer memory, and bounds native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is source channel/rate metadata contracts, explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. +This policy now bounds Python decode/model entry by decoded sample count and decoded mono-buffer memory, and bounds native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The completed-path containment check is a point-in-time canonical path check and does not claim descriptor/handle-level race freedom if a privileged local actor replaces filesystem entries after validation. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is source channel/rate metadata contracts, explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. ## References From 7eca596fe5843a1c07282ab810ee9fb31248c2a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:18:21 -0700 Subject: [PATCH 068/448] docs(changelog): record YouTube completed-path containment --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7969e2402..c4ed41299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. - Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. - Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. -- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, and delete post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. +- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. ## [0.1.3] - 2026-04-29 From 6a728fda304b942bfb9969bcfe527829dcbd3377 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 15:45:21 -0700 Subject: [PATCH 069/448] test(audio): exercise module entrypoint with owned path --- .../analysis-engine/tests/test_youtube.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 35b0d6689..0e9f1a678 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -679,38 +679,35 @@ def test_main_block(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixtu def test_module_execution( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, ) -> None: - """Test the if __name__ == '__main__' block using runpy.""" + """Test module execution against a real owned output path without network I/O.""" import runpy import bandscope_analysis.youtube + downloaded_path = tmp_path / "abc123DEF45.m4a" + downloaded_path.write_bytes(b"test-audio") test_args = [ "youtube.py", "--url", "https://youtube.com/watch?v=abc123DEF45", "--out-dir", - "/tmp", + str(tmp_path), ] monkeypatch.setattr(sys, "argv", test_args) - # Mock yt_dlp so runpy doesn't actually download + # Mock only the downloader/network boundary. Real filesystem semantics are + # required so the completed-path ownership check remains exercised. mock_yt_dlp = MagicMock() mock_ydl = MagicMock() mock_yt_dlp.YoutubeDL.return_value.__enter__.return_value = mock_ydl mock_ydl.extract_info.return_value = {"id": "abc123DEF45"} - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_ydl.prepare_filename.return_value = str(downloaded_path) monkeypatch.setitem(sys.modules, "yt_dlp", mock_yt_dlp) - # Mock os to ensure runpy uses our mocked filesystem methods - mock_os = MagicMock() - # Keep some essential attributes - mock_os.path = MagicMock() - mock_os.path.exists.return_value = True - mock_os.path.getsize.return_value = 10 * 1024 * 1024 - monkeypatch.setitem(sys.modules, "os", mock_os) - with patch.object(sys, "exit") as mock_exit: runpy.run_path(bandscope_analysis.youtube.__file__, run_name="__main__") mock_exit.assert_called_with(0) @@ -738,4 +735,4 @@ def test_download_youtube_audio_second_info_none(mock_ydl_class: MagicMock) -> N assert result["error"]["code"] == "download_error" assert result["error"]["message"] == ( "YouTube import failed. Please use a local audio file instead." - ) + ) \ No newline at end of file From 2b836818f4faea03f1359a5de52d726bbdbf62e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:13:54 -0700 Subject: [PATCH 070/448] style(tests): restore Ruff formatting --- services/analysis-engine/tests/test_youtube.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 0e9f1a678..f7b0f863c 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -735,4 +735,4 @@ def test_download_youtube_audio_second_info_none(mock_ydl_class: MagicMock) -> N assert result["error"]["code"] == "download_error" assert result["error"]["message"] == ( "YouTube import failed. Please use a local audio file instead." - ) \ No newline at end of file + ) From 2909bc8950c69949f205243c3c81f74927f2de61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:45:10 -0700 Subject: [PATCH 071/448] test(audio): reject malformed model stem output --- .../tests/test_audio_model_output_policy.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_model_output_policy.py diff --git a/services/analysis-engine/tests/test_audio_model_output_policy.py b/services/analysis-engine/tests/test_audio_model_output_policy.py new file mode 100644 index 000000000..917d19431 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_model_output_policy.py @@ -0,0 +1,33 @@ +"""Regression tests for fail-closed source-separation model output.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bandscope_analysis.separation.audio_separator import _as_float_array + + +@pytest.mark.parametrize( + "values", + [ + np.array([], dtype=np.float32), + np.array([np.nan], dtype=np.float32), + np.array([np.inf], dtype=np.float32), + np.array([np.finfo(np.float64).max], dtype=np.float64), + ], +) +def test_model_output_rejects_empty_nonfinite_or_float32_overflow(values: np.ndarray) -> None: + """Malformed model stems must fail instead of becoming successful silence.""" + with pytest.raises(ValueError, match="^Stem separation produced invalid audio\.$"): + _as_float_array(values) + + +def test_model_output_preserves_valid_finite_samples() -> None: + """Valid model samples remain finite float32 audio with their original values.""" + values = np.array([0.25, -0.5, 0.75], dtype=np.float64) + + result = _as_float_array(values) + + assert result.dtype == np.float32 + assert np.array_equal(result, values.astype(np.float32)) From e5293a9626cf94af0ee51406a3c409c81a709c54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:47:55 -0700 Subject: [PATCH 072/448] fix(audio): reject malformed model stem output --- .../separation/audio_separator.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 7b5268ed8..f03015d8e 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -12,6 +12,8 @@ - Decoded audio is revalidated against the same versioned resource policy before Demucs/model work so overlong, malformed, or non-finite decoder output fails closed instead of being silently truncated or normalized. +- Empty, non-finite, or float32-overflowed model stems fail closed before they + can become successful silence or downstream rehearsal evidence. - Inference runs locally on CPU with no network access. The model weights are loaded from the local Demucs cache or a configured bundled path; offline weight bundling is tracked in the supplemental component inventory. @@ -51,6 +53,7 @@ # Demucs htdemucs emits these four sources; this is the canonical stem set. _STEM_ORDER: tuple[AudioStemName, ...] = ("vocals", "bass", "drums", "other") _EMPTY_RANGE_EPS = 1e-9 +_MODEL_OUTPUT_ERROR = "Stem separation produced invalid audio." def _contains_parent_path_segment(path: Path) -> bool: @@ -250,7 +253,12 @@ def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArr def _as_float_array(values: object) -> AudioStemArray: - """Convert decoder and model output to a finite one-dimensional float array.""" - array = np.ravel(np.asarray(values, dtype=np.float32)) - finite = np.nan_to_num(array, copy=False, nan=0.0, posinf=0.0, neginf=0.0) - return cast(AudioStemArray, finite) + """Convert one finite, non-empty decoder/model output into mono float32 audio.""" + try: + with np.errstate(over="ignore", invalid="ignore"): + array = np.ravel(np.asarray(values, dtype=np.float32)) + except (OverflowError, TypeError, ValueError) as error: + raise ValueError(_MODEL_OUTPUT_ERROR) from error + if array.size == 0 or not np.isfinite(array).all(): + raise ValueError(_MODEL_OUTPUT_ERROR) + return cast(AudioStemArray, array) From 2464ff940425d7a9e176ad5a04aba30937775fc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:05:34 -0700 Subject: [PATCH 073/448] test(audio): use explicit raw error pattern --- .../analysis-engine/tests/test_audio_model_output_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_model_output_policy.py b/services/analysis-engine/tests/test_audio_model_output_policy.py index 917d19431..332764713 100644 --- a/services/analysis-engine/tests/test_audio_model_output_policy.py +++ b/services/analysis-engine/tests/test_audio_model_output_policy.py @@ -19,7 +19,7 @@ ) def test_model_output_rejects_empty_nonfinite_or_float32_overflow(values: np.ndarray) -> None: """Malformed model stems must fail instead of becoming successful silence.""" - with pytest.raises(ValueError, match="^Stem separation produced invalid audio\.$"): + with pytest.raises(ValueError, match=r"^Stem separation produced invalid audio\.$"): _as_float_array(values) From c35d55dac99de52604011c981074976b02357b45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:18:13 -0700 Subject: [PATCH 074/448] test(audio): cover model conversion failures --- .../analysis-engine/tests/test_audio_model_output_policy.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_model_output_policy.py b/services/analysis-engine/tests/test_audio_model_output_policy.py index 332764713..863b5418c 100644 --- a/services/analysis-engine/tests/test_audio_model_output_policy.py +++ b/services/analysis-engine/tests/test_audio_model_output_policy.py @@ -23,6 +23,12 @@ def test_model_output_rejects_empty_nonfinite_or_float32_overflow(values: np.nda _as_float_array(values) +def test_model_output_wraps_non_numeric_conversion_errors() -> None: + """Non-numeric model output must fail with the stable payload-free error.""" + with pytest.raises(ValueError, match=r"^Stem separation produced invalid audio\.$"): + _as_float_array(object()) + + def test_model_output_preserves_valid_finite_samples() -> None: """Valid model samples remain finite float32 audio with their original values.""" values = np.array([0.25, -0.5, 0.75], dtype=np.float64) From 15a9edb79f7d6ec4378a3e808c80cbfb39a7af63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:21:11 -0700 Subject: [PATCH 075/448] test(audio): reproduce GPU tensor NumPy boundary --- .../test_audio_separator_device_boundary.py | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_separator_device_boundary.py diff --git a/services/analysis-engine/tests/test_audio_separator_device_boundary.py b/services/analysis-engine/tests/test_audio_separator_device_boundary.py new file mode 100644 index 000000000..e8545876b --- /dev/null +++ b/services/analysis-engine/tests/test_audio_separator_device_boundary.py @@ -0,0 +1,109 @@ +"""Device-boundary regressions for local Demucs separation.""" + +from __future__ import annotations + +import sys +from types import ModuleType + +import numpy as np +import pytest + +from bandscope_analysis.separation.audio_separator import AudioSeparationConfig, AudioStemSeparator + + +class _FakeModel: + """Expose the canonical Demucs source order used by production.""" + + sources = ["drums", "bass", "other", "vocals"] + + +class _DeviceTensor: + """Minimal tensor that refuses NumPy conversion until moved to CPU.""" + + def __init__(self, array: np.ndarray, *, on_cpu: bool) -> None: + self.array = np.asarray(array, dtype=np.float32) + self.on_cpu = on_cpu + + def float(self) -> "_DeviceTensor": + return _DeviceTensor(self.array.astype(np.float32), on_cpu=self.on_cpu) + + def mean(self, axis: int | None = None) -> float | "_DeviceTensor": + value = self.array.mean(axis=axis) + if axis is None: + return float(value) + return _DeviceTensor(np.asarray(value, dtype=np.float32), on_cpu=self.on_cpu) + + def std(self) -> float: + return float(self.array.std()) + + def cpu(self) -> "_DeviceTensor": + return _DeviceTensor(self.array, on_cpu=True) + + def numpy(self) -> np.ndarray: + if not self.on_cpu: + raise RuntimeError("can't convert cuda tensor to numpy") + return self.array + + def __getitem__(self, key: object) -> "_DeviceTensor": + return _DeviceTensor(self.array[key], on_cpu=self.on_cpu) + + def __add__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array + value, on_cpu=self.on_cpu) + + def __sub__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array - value, on_cpu=self.on_cpu) + + def __mul__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array * value, on_cpu=self.on_cpu) + + def __truediv__(self, value: float) -> "_DeviceTensor": + return _DeviceTensor(self.array / value, on_cpu=self.on_cpu) + + +class _NoGrad: + def __enter__(self) -> None: + return None + + def __exit__(self, *args: object) -> None: + return None + + +def test_apply_model_moves_device_output_to_cpu_before_numpy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GPU-selected separation must cross the device boundary before NumPy conversion.""" + calls: dict[str, object] = {} + fake_torch = ModuleType("torch") + fake_torch.from_numpy = lambda array: _DeviceTensor(array, on_cpu=True) # type: ignore[attr-defined] + fake_torch.no_grad = _NoGrad # type: ignore[attr-defined] + + def fake_apply_model( + model: _FakeModel, + batch: _DeviceTensor, + *, + device: str, + split: bool, + overlap: float, + progress: bool, + ) -> _DeviceTensor: + calls.update(device=device, split=split, overlap=overlap, progress=progress) + source_values = np.arange(len(model.sources), dtype=np.float32).reshape(-1, 1, 1) + separated = np.broadcast_to(source_values, (len(model.sources), 2, 4)).copy() + return _DeviceTensor(separated[None], on_cpu=False) + + demucs_module = ModuleType("demucs") + apply_module = ModuleType("demucs.apply") + apply_module.apply_model = fake_apply_model # type: ignore[attr-defined] + demucs_module.apply = apply_module # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setitem(sys.modules, "demucs", demucs_module) + monkeypatch.setitem(sys.modules, "demucs.apply", apply_module) + + audio = np.array([0.0, 1.0, -1.0, 0.5], dtype=np.float32) + separator = AudioStemSeparator(AudioSeparationConfig(device="cuda", overlap=0.375)) + + result = separator._apply_model(_FakeModel(), audio) + + assert calls == {"device": "cuda", "split": True, "overlap": 0.375, "progress": False} + assert set(result) == set(_FakeModel.sources) + assert all(stem.shape == (4,) for stem in result.values()) From c5519a46372b5dec1e20bf43bcb05995f94a1955 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:21:57 -0700 Subject: [PATCH 076/448] fix(audio): move accelerator stems to CPU before NumPy --- .../separation/audio_separator.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index f03015d8e..a1bc6f028 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -14,9 +14,11 @@ closed instead of being silently truncated or normalized. - Empty, non-finite, or float32-overflowed model stems fail closed before they can become successful silence or downstream rehearsal evidence. -- Inference runs locally on CPU with no network access. The model weights are - loaded from the local Demucs cache or a configured bundled path; offline - weight bundling is tracked in the supplemental component inventory. +- Inference runs locally with no network access. Model outputs cross back to CPU + before NumPy conversion so configured accelerator execution cannot fail at the + device/host boundary. The model weights are loaded from the local Demucs cache + or a configured bundled path; offline weight bundling is tracked in the + supplemental component inventory. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -133,8 +135,8 @@ def _separate_signal( """Run the Demucs model on mono audio and return canonical mono stems. This is the single boundary to the neural model; it converts the mono - signal to the stereo tensor Demucs expects, applies the model on CPU, and - downmixes each source back to a mono float array. + signal to the stereo tensor Demucs expects, applies the model on the + configured device, and downmixes each source back to a mono host array. """ model = self._load_model() sources = self._apply_model(model, audio) @@ -186,7 +188,7 @@ def _apply_model(self, model: Any, audio: AudioStemArray) -> dict[str, np.ndarra progress=False, )[0] out = out * ref_std + ref_mean - return {name: out[i].mean(0).numpy() for i, name in enumerate(model.sources)} + return {name: out[i].mean(0).cpu().numpy() for i, name in enumerate(model.sources)} def _resolve_audio_file(self, audio_path: str | Path) -> Path: """Normalize and validate the selected source path.""" From 389f0572d4f49248b56c3f0b5ed81c91f9c4bbe9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:22:51 -0700 Subject: [PATCH 077/448] fix(audio): preserve CPU tests while bridging accelerator stems --- .../separation/audio_separator.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index a1bc6f028..666451af9 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -14,10 +14,10 @@ closed instead of being silently truncated or normalized. - Empty, non-finite, or float32-overflowed model stems fail closed before they can become successful silence or downstream rehearsal evidence. -- Inference runs locally with no network access. Model outputs cross back to CPU - before NumPy conversion so configured accelerator execution cannot fail at the - device/host boundary. The model weights are loaded from the local Demucs cache - or a configured bundled path; offline weight bundling is tracked in the +- Inference runs locally with no network access. Accelerator outputs cross back + to CPU before NumPy conversion so configured device execution cannot fail at + the device/host boundary. The model weights are loaded from the local Demucs + cache or a configured bundled path; offline weight bundling is tracked in the supplemental component inventory. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe @@ -188,7 +188,13 @@ def _apply_model(self, model: Any, audio: AudioStemArray) -> dict[str, np.ndarra progress=False, )[0] out = out * ref_std + ref_mean - return {name: out[i].mean(0).cpu().numpy() for i, name in enumerate(model.sources)} + stems: dict[str, np.ndarray[Any, Any]] = {} + for index, name in enumerate(model.sources): + stem = out[index].mean(0) + if self.config.device != "cpu": + stem = stem.cpu() + stems[name] = stem.numpy() + return stems def _resolve_audio_file(self, audio_path: str | Path) -> Path: """Normalize and validate the selected source path.""" From 2ea9e07069b36892d7fe00e0f0fc28ddb6dbe2e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:12:31 -0700 Subject: [PATCH 078/448] test(security): reproduce stem worker log disclosure --- .../test_stem_separation_logging_privacy.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 services/analysis-engine/tests/test_stem_separation_logging_privacy.py diff --git a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py new file mode 100644 index 000000000..fd22e7ebc --- /dev/null +++ b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py @@ -0,0 +1,51 @@ +"""Regression tests for stem-separation worker logging privacy.""" + +import logging + +import pytest + +import bandscope_analysis.api as analysis_api + + +class _ResultQueue: + """Capture the worker result without starting a multiprocessing queue.""" + + def __init__(self) -> None: + self.items: list[tuple[object, object]] = [] + + def put(self, item: tuple[object, object]) -> None: + """Record one result emitted by the worker.""" + self.items.append(item) + + +class _FailingSeparator: + """Raise dependency-controlled sensitive text from the separator boundary.""" + + def separate(self, source_path: str) -> dict[str, object]: + """Simulate a dependency failure after receiving an authorized source path.""" + raise RuntimeError( + f"decoder failed for {source_path} /Users/Alice/private-song.wav token=super-secret" + ) + + +def test_stem_worker_failure_log_omits_dependency_payload_and_traceback( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Routine worker diagnostics must not retain dependency payloads or tracebacks.""" + result_queue = _ResultQueue() + source_path = "/private/customer/Alice/session.wav" + + monkeypatch.setattr(analysis_api, "AudioStemSeparator", _FailingSeparator) + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + analysis_api._stem_separation_worker(source_path, result_queue) + + assert result_queue.items == [ + ("runtime_error", "Runtime error occurred during stem separation.") + ] + assert "Stem separation failed with a runtime error." in caplog.text + assert source_path not in caplog.text + assert "private-song.wav" not in caplog.text + assert "super-secret" not in caplog.text + assert all(record.exc_info is None for record in caplog.records) From b7dd015c7177805f9c2756edd2356b9e0fecfca5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:17:17 -0700 Subject: [PATCH 079/448] test(security): cover parent stem failure logs --- .../test_stem_separation_logging_privacy.py | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py index fd22e7ebc..e9ba6b8b3 100644 --- a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py +++ b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py @@ -1,4 +1,4 @@ -"""Regression tests for stem-separation worker logging privacy.""" +"""Regression tests for stem-separation logging privacy.""" import logging @@ -28,6 +28,31 @@ def separate(self, source_path: str) -> dict[str, object]: ) +def _local_audio_request() -> dict[str, object]: + """Return a valid local-audio request without cache or temporary-path authority.""" + return { + "sourceKind": "local_audio", + "projectId": "privacy-regression", + "sourceLabel": "private-song.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/private/customer/Alice/session.wav", + "fileName": "private-song.wav", + "extension": "wav", + "fileSizeBytes": 1024, + }, + } + + +def _assert_payload_free_log(caplog: pytest.LogCaptureFixture) -> None: + """Require routine logs to omit dependency payloads and exception tracebacks.""" + assert "/private/customer/Alice/session.wav" not in caplog.text + assert "/Users/Alice/private-song.wav" not in caplog.text + assert "private-song.wav token=super-secret" not in caplog.text + assert "super-secret" not in caplog.text + assert all(record.exc_info is None for record in caplog.records) + + def test_stem_worker_failure_log_omits_dependency_payload_and_traceback( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -45,7 +70,35 @@ def test_stem_worker_failure_log_omits_dependency_payload_and_traceback( ("runtime_error", "Runtime error occurred during stem separation.") ] assert "Stem separation failed with a runtime error." in caplog.text - assert source_path not in caplog.text - assert "private-song.wav" not in caplog.text - assert "super-secret" not in caplog.text - assert all(record.exc_info is None for record in caplog.records) + _assert_payload_free_log(caplog) + + +def test_analysis_job_stem_failure_log_omits_dependency_payload_and_traceback( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Parent orchestration failure logs must keep dependency details out of routine logs.""" + sensitive_detail = ( + "decode failed for /private/customer/Alice/session.wav " + "/Users/Alice/private-song.wav token=super-secret" + ) + + def fail_features(_request: analysis_api.AnalysisJobRequest) -> None: + raise ValueError(sensitive_detail) + + monkeypatch.setattr(analysis_api, "_build_local_audio_features", fail_features) + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + updates = analysis_api.run_analysis_job_updates( + "job-privacy", + _local_audio_request(), + "2026-08-20T00:00:00Z", + ) + + assert updates[-1]["state"] == "failed" + assert updates[-1]["error"] == { + "code": "engine_unavailable", + "message": "Stem separation failed", + } + assert "Stem separation failed before analysis job completion." in caplog.text + _assert_payload_free_log(caplog) From cf8b62ee4f9b8e44893219ea99ef0e8755deeb7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:18:13 -0700 Subject: [PATCH 080/448] fix(security): redact analysis API tracebacks --- .../src/bandscope_analysis/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index 3867248e8..2efba8173 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -1,5 +1,21 @@ """BandScope analysis engine package.""" +import logging + + +class _ApiDiagnosticPrivacyFilter(logging.Filter): + """Remove traceback payloads from the public analysis API's routine diagnostics.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Keep the safe operation message while discarding exception traceback state.""" + record.exc_info = None + record.exc_text = None + return True + + +_api_logger = logging.getLogger("bandscope_analysis.api") +_api_logger.addFilter(_ApiDiagnosticPrivacyFilter()) + from .api import get_analysis_status from .health import build_health_report From 2f281befa063841755198a66873628d9869915cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:19:10 -0700 Subject: [PATCH 081/448] fix(security): keep API privacy init lint-safe --- .../analysis-engine/src/bandscope_analysis/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index 2efba8173..dcf76d5f6 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -1,6 +1,9 @@ """BandScope analysis engine package.""" import logging +from importlib import import_module + +from .health import build_health_report class _ApiDiagnosticPrivacyFilter(logging.Filter): @@ -15,8 +18,7 @@ def filter(self, record: logging.LogRecord) -> bool: _api_logger = logging.getLogger("bandscope_analysis.api") _api_logger.addFilter(_ApiDiagnosticPrivacyFilter()) - -from .api import get_analysis_status -from .health import build_health_report +_api_module = import_module(".api", __name__) +get_analysis_status = _api_module.get_analysis_status __all__ = ["build_health_report", "get_analysis_status"] From a8bbb77c77e5cb25809daeca8494f1ba8f38f2bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:44:56 -0700 Subject: [PATCH 082/448] test(security): scope analysis log redaction --- .../test_stem_separation_logging_privacy.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py index e9ba6b8b3..8d7d2d7b1 100644 --- a/services/analysis-engine/tests/test_stem_separation_logging_privacy.py +++ b/services/analysis-engine/tests/test_stem_separation_logging_privacy.py @@ -102,3 +102,23 @@ def fail_features(_request: analysis_api.AnalysisJobRequest) -> None: } assert "Stem separation failed before analysis job completion." in caplog.text _assert_payload_free_log(caplog) + + +def test_api_logger_preserves_unrelated_exception_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """Privacy redaction must not erase traceback evidence from unrelated API diagnostics.""" + caplog.set_level(logging.ERROR, logger=analysis_api.__name__) + + try: + raise RuntimeError("non-sensitive diagnostic sentinel") + except RuntimeError: + analysis_api.logger.exception("Unrelated analysis API diagnostic.") + + records = [ + record + for record in caplog.records + if record.getMessage() == "Unrelated analysis API diagnostic." + ] + assert len(records) == 1 + assert records[0].exc_info is not None From 223dd78126deeb3f12a68dc140f6a83fbe422225 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:46:38 -0700 Subject: [PATCH 083/448] fix(security): scope stem diagnostic redaction --- .../src/bandscope_analysis/__init__.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index dcf76d5f6..0cf11033b 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -6,13 +6,26 @@ from .health import build_health_report +_STEM_SAFE_FAILURE_LOG_MESSAGES = frozenset( + { + "Stem separation failed because the source file was missing.", + "Stem separation unavailable because Demucs or torch is not installed.", + "Stem separation rejected invalid audio source data.", + "Stem separation failed with a runtime error.", + "Stem separation failed unexpectedly.", + "Stem separation failed before analysis job completion.", + } +) + + class _ApiDiagnosticPrivacyFilter(logging.Filter): - """Remove traceback payloads from the public analysis API's routine diagnostics.""" + """Redact traceback payloads only for known stem safe-failure diagnostics.""" def filter(self, record: logging.LogRecord) -> bool: - """Keep the safe operation message while discarding exception traceback state.""" - record.exc_info = None - record.exc_text = None + """Preserve unrelated diagnostics while redacting owned safe-failure tracebacks.""" + if record.getMessage() in _STEM_SAFE_FAILURE_LOG_MESSAGES: + record.exc_info = None + record.exc_text = None return True From 29acea4a15b5999a04af44d7f9887eac24cc2e26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:49:07 -0700 Subject: [PATCH 084/448] test(project): require no-clobber staged publication --- .../src-tauri/src/project_persistence.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/desktop/src-tauri/src/project_persistence.rs 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..a19a7b515 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,50 @@ +#[cfg(test)] +mod tests { + use super::publish_new_project_file; + 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 + } + + #[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 refuses_to_clobber_an_existing_known_good_project() { + let root = test_dir("existing"); + 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, br#"{\"id\":\"replacement\"}"#) + .expect_err("existing project must not be overwritten unsafely"); + + assert_eq!(error, "Project file already exists. Choose a new file name."); + assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} From e673356618c26cb6bd2ecacab49f9b17c6c0bcb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:51:49 -0700 Subject: [PATCH 085/448] test(project): wire no-clobber publication regression --- apps/desktop/src-tauri/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..bcc56ceca 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}; From f4761810ce6a433ac2cb14ed2ad44794d728c552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:53:03 -0700 Subject: [PATCH 086/448] fix(project): stage and publish new saves without clobber --- .../src-tauri/src/project_persistence.rs | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a19a7b515..dd8c58311 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1,7 +1,72 @@ +use std::{ + ffi::OsString, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, +}; + +const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 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."; + +fn staging_path(target: &Path) -> Result { + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; + let mut stage_name = OsString::from("."); + stage_name.push(file_name); + stage_name.push(format!(".{}.stage", uuid::Uuid::new_v4())); + Ok(parent.join(stage_name)) +} + +fn remove_stage(path: &Path) { + let _ = fs::remove_file(path); +} + +/// Publishes one new project only after its complete bounded bytes are staged and synced. +/// +/// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the +/// staging name non-clobbering, and `hard_link` atomically creates the user-selected destination +/// only if that destination is still absent. An existing file or dangling symlink therefore stays +/// untouched instead of being truncated before replacement bytes are durable. Crash-safe overwrite, +/// backup rotation, migration, and recovery remain separate project-format work under #962. +pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { + return Err(PROJECT_STAGE_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() || staged.sync_all().is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + drop(staged); + + if let Err(error) = fs::hard_link(&stage, target) { + remove_stage(&stage); + return if error.kind() == std::io::ErrorKind::AlreadyExists { + Err(PROJECT_EXISTS_ERROR.to_string()) + } else { + Err(PROJECT_PUBLISH_ERROR.to_string()) + }; + } + + // Both names reference the already-synced inode at this point. Cleanup failure does not make the + // published target partial, so do not report a false save failure after publication succeeded. + remove_stage(&stage); + Ok(()) +} + #[cfg(test)] mod tests { - use super::publish_new_project_file; - use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + use super::{publish_new_project_file, MAX_PROJECT_FILE_BYTES}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; fn test_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -47,4 +112,19 @@ mod tests { assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); 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, "Could not stage the project safely."); + 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"); + } } From 18d5812024374210b4e075e36a1d46db37403f49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:10:44 -0700 Subject: [PATCH 087/448] test(project): require safe save publication wiring --- apps/desktop/src-tauri/src/project_persistence.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index dd8c58311..942b863fb 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -127,4 +127,18 @@ mod tests { 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 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 non-clobbering publisher" + ); + assert!( + !main_source.contains("std::fs::write(path, content)"), + "the Tauri save command must not truncate the selected destination directly" + ); + } } From 200eac3cddcbbdc3c7ea1dbfa0c81d54cf9f2cdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:12:15 -0700 Subject: [PATCH 088/448] fix(project): publish saves without clobbering existing files --- apps/desktop/src-tauri/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index bcc56ceca..984243039 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -752,7 +752,7 @@ fn save_project(payload: Value) -> Result<(), 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())?; + project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) } From 1ecb7c495299ab3486e14d7e2c076c7e3b3edaa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:50:11 -0700 Subject: [PATCH 089/448] test(project): require bounded project reads --- .../src-tauri/src/project_persistence.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 942b863fb..93e091f05 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -61,7 +61,7 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< #[cfg(test)] mod tests { - use super::{publish_new_project_file, MAX_PROJECT_FILE_BYTES}; + use super::{publish_new_project_file, read_project_file, MAX_PROJECT_FILE_BYTES}; use std::{ fs, path::PathBuf, @@ -128,6 +128,36 @@ mod tests { 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"); + } + + #[test] + fn rejects_oversized_project_during_the_read_itself() { + let root = test_dir("read-oversize"); + let target = root.join("setlist.bscope"); + let file = 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 5MB limit)"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + #[test] fn save_project_command_routes_through_safe_publisher() { let main_source = include_str!("main.rs"); @@ -141,4 +171,18 @@ mod tests { "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" + ); + } } From f852054727354094cbc87ee36e3230d863da5cfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:50:48 -0700 Subject: [PATCH 090/448] fix(project): bound project reads at the file boundary --- .../src-tauri/src/project_persistence.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 93e091f05..0b866448a 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1,7 +1,7 @@ use std::{ ffi::OsString, fs::{self, File}, - io::Write, + io::{Read, Write}, path::{Path, PathBuf}, }; @@ -9,6 +9,8 @@ const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 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 5MB limit)"; fn staging_path(target: &Path) -> Result { let parent = target.parent().unwrap_or_else(|| Path::new(".")); @@ -23,6 +25,26 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } +/// Reads one project through the same bounded byte ceiling used by project publication. +/// +/// The file is opened once and the reader itself is capped at `MAX_PROJECT_FILE_BYTES + 1`, so a +/// file that grows after selection cannot turn a metadata preflight into an unbounded allocation. +/// UTF-8 decoding happens only after the bounded read completes. Path selection remains owned by the +/// native file dialog; symlink/handle-level containment and durable project recovery are later #962 +/// boundaries rather than claims of this helper. +pub(crate) fn read_project_file(target: &Path) -> Result { + let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let mut reader = file.take((MAX_PROJECT_FILE_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_PROJECT_FILE_BYTES { + return Err(PROJECT_TOO_LARGE_ERROR.to_string()); + } + String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) +} + /// Publishes one new project only after its complete bounded bytes are staged and synced. /// /// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the @@ -146,7 +168,7 @@ mod tests { fn rejects_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); let target = root.join("setlist.bscope"); - let file = File::create(&target).expect("fixture should be created"); + 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); From eae423c44fb39b89c33d534d65f47ab6552a1cea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:53:14 -0700 Subject: [PATCH 091/448] fix(project): route load through bounded reader --- apps/desktop/src-tauri/src/main.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 984243039..0c78506be 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -764,12 +764,7 @@ fn load_project() -> Result { .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())?; + let content = project_persistence::read_project_file(&path)?; project_payload_from_content(&content) } From f99d41f83a723f7256ae9993da041f8d9584fe20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:53:46 -0700 Subject: [PATCH 092/448] docs(changelog): record bounded project persistence --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..7fd9fda87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- 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. + ## [0.1.3] - 2026-04-29 ### Fixed From 935bfa821f350faba3b08346d94329159c396113 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:41:31 -0700 Subject: [PATCH 093/448] test(project): reject symlink project reads --- .../src-tauri/src/project_persistence.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 0b866448a..1ce4a4d19 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -164,6 +164,24 @@ mod tests { 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_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); From 20767156c10158863a8a320aeffa92c18843aebb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:42:05 -0700 Subject: [PATCH 094/448] fix(project): reject directly selected symlink loads --- .../desktop/src-tauri/src/project_persistence.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 1ce4a4d19..3ad7da562 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -27,12 +27,18 @@ fn remove_stage(path: &Path) { /// Reads one project through the same bounded byte ceiling used by project publication. /// -/// The file is opened once and the reader itself is capped at `MAX_PROJECT_FILE_BYTES + 1`, so a -/// file that grows after selection cannot turn a metadata preflight into an unbounded allocation. -/// UTF-8 decoding happens only after the bounded read completes. Path selection remains owned by the -/// native file dialog; symlink/handle-level containment and durable project recovery are later #962 -/// boundaries rather than claims of this helper. +/// A directly selected symlink is rejected before it can redirect the read to different content. +/// The regular file is then opened once and the reader itself is capped at +/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata +/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read +/// completes. Handle-level identity checks for a path swapped between inspection and open remain a +/// later #962 boundary and are not claimed by this helper. pub(crate) fn read_project_file(target: &Path) -> Result { + let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if metadata.file_type().is_symlink() { + return Err(PROJECT_READ_ERROR.to_string()); + } + let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); From d38862c3dd6bf789b907196a086ead1e08636065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:42:34 -0700 Subject: [PATCH 095/448] docs(changelog): record symlink-safe project loading --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd9fda87..176cc70f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - 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. ## [0.1.3] - 2026-04-29 From ad1d79198e7909c046569a6197718f933907b6d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:09:52 -0700 Subject: [PATCH 096/448] test(project): prove load TOCTOU path swap --- .../src-tauri/src/project_persistence.rs | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 3ad7da562..7f620abb3 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -25,21 +25,16 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } -/// Reads one project through the same bounded byte ceiling used by project publication. -/// -/// A directly selected symlink is rejected before it can redirect the read to different content. -/// The regular file is then opened once and the reader itself is capped at -/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata -/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read -/// completes. Handle-level identity checks for a path swapped between inspection and open remain a -/// later #962 boundary and are not claimed by this helper. -pub(crate) fn read_project_file(target: &Path) -> Result { +fn read_project_file_with_opener(target: &Path, open_file: F) -> Result +where + F: FnOnce(&Path) -> std::io::Result, +{ let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; if metadata.file_type().is_symlink() { return Err(PROJECT_READ_ERROR.to_string()); } - let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); reader @@ -51,6 +46,18 @@ pub(crate) fn read_project_file(target: &Path) -> Result { String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) } +/// Reads one project through the same bounded byte ceiling used by project publication. +/// +/// A directly selected symlink is rejected before it can redirect the read to different content. +/// The regular file is then opened once and the reader itself is capped at +/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata +/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read +/// completes. Handle-level identity checks for a path swapped between inspection and open remain a +/// later #962 boundary and are not claimed by this helper. +pub(crate) fn read_project_file(target: &Path) -> Result { + read_project_file_with_opener(target, File::open) +} + /// Publishes one new project only after its complete bounded bytes are staged and synced. /// /// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the @@ -89,7 +96,10 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< #[cfg(test)] mod tests { - use super::{publish_new_project_file, read_project_file, MAX_PROJECT_FILE_BYTES}; + use super::{ + publish_new_project_file, read_project_file, read_project_file_with_opener, + MAX_PROJECT_FILE_BYTES, + }; use std::{ fs, path::PathBuf, @@ -188,6 +198,27 @@ mod tests { 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) + }) + .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 directory should be removable"); + } + #[test] fn rejects_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); From 6cc163cca1a92178472d7e2688282b336a5a0595 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:11:00 -0700 Subject: [PATCH 097/448] fix(project): bind load preflight to opened file --- .../src-tauri/src/project_persistence.rs | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 7f620abb3..c12058629 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -12,6 +12,11 @@ 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 5MB limit)"; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + fn staging_path(target: &Path) -> Result { let parent = target.parent().unwrap_or_else(|| Path::new(".")); let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; @@ -25,16 +30,76 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } +#[cfg(windows)] +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(not(windows))] +fn open_project_file(target: &Path) -> std::io::Result { + File::open(target) +} + +#[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)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + left.file_attributes() == right.file_attributes() + && left.creation_time() == right.creation_time() + && left.last_write_time() == right.last_write_time() + && left.file_size() == right.file_size() +} + +#[cfg(not(any(unix, windows)))] +fn same_file_identity(_left: &fs::Metadata, _right: &fs::Metadata) -> bool { + false +} + +#[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() +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, { - let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; - if metadata.file_type().is_symlink() { + 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()); } 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) + || !same_file_identity(&before, &opened) + || !same_file_identity(&opened, &after) + { + return Err(PROJECT_READ_ERROR.to_string()); + } + let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); reader @@ -46,16 +111,17 @@ where String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) } -/// Reads one project through the same bounded byte ceiling used by project publication. +/// Reads one project through a bounded, path-stable native file handle. /// -/// A directly selected symlink is rejected before it can redirect the read to different content. -/// The regular file is then opened once and the reader itself is capped at -/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata -/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read -/// completes. Handle-level identity checks for a path swapped between inspection and open remain a -/// later #962 boundary and are not claimed by this helper. +/// The selected path must name the same regular file before the open, on the opened handle, and +/// immediately after the open. Unix builds compare device/inode identity. Windows opens the reparse +/// point itself rather than following it and rejects reparse handles, then requires the stable file +/// metadata revision to match around the open. This closes the selected-path swap between the +/// preflight and handle acquisition without adding a dependency or granting JavaScript path +/// authority. The reader remains capped at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, and +/// recovery semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { - read_project_file_with_opener(target, File::open) + read_project_file_with_opener(target, open_project_file) } /// Publishes one new project only after its complete bounded bytes are staged and synced. From aec9c4ee335ae73dc16972ed3b265800a8f6e78b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:11:40 -0700 Subject: [PATCH 098/448] docs(changelog): record project load identity guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 176cc70f4..fcb50b290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 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. +- Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows also opens reparse points without following them before validation. ## [0.1.3] - 2026-04-29 From 33da8db18308b213ca31d0f2e1d5b9a79292bcc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:32:56 -0700 Subject: [PATCH 099/448] test(project): reject symlinked save parent --- .../project_persistence_parent_symlink.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs 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..f6de3f8c0 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -0,0 +1,44 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_parent_directory() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-persistence-parent-symlink-{}-{nonce}", + std::process::id() + )); + 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"); +} From 3bc0114446ac42ebba3b8fb70e02d35c3e94c456 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:34:18 -0700 Subject: [PATCH 100/448] fix(project): reject symlinked save parent --- .../src-tauri/src/project_persistence.rs | 79 +++++++++++++++---- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index c12058629..37fc34928 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -17,9 +17,18 @@ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; #[cfg(windows)] const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +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 = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; + let parent = project_parent(target); + let file_name = target + .file_name() + .ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; let mut stage_name = OsString::from("."); stage_name.push(file_name); stage_name.push(format!(".{}.stage", uuid::Uuid::new_v4())); @@ -80,6 +89,18 @@ 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() +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -126,16 +147,25 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// Publishes one new project only after its complete bounded bytes are staged and synced. /// -/// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the -/// staging name non-clobbering, and `hard_link` atomically creates the user-selected destination -/// only if that destination is still absent. An existing file or dangling symlink therefore stays -/// untouched instead of being truncated before replacement bytes are durable. Crash-safe overwrite, -/// backup rotation, migration, and recovery remain separate project-format work under #962. +/// This helper deliberately does not implement overwrite semantics. The directly selected parent +/// must itself be a real directory rather than a symlink/reparse point before any staging artifact is +/// created. `File::create_new` makes the staging name non-clobbering, and `hard_link` atomically +/// creates the user-selected destination only if that destination is still absent. An existing file +/// or dangling symlink therefore stays untouched instead of being truncated before replacement bytes +/// are durable. Crash-safe overwrite, ancestor-handle binding, parent-directory durability, backup +/// rotation, migration, and recovery remain separate project-format work under #962. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { return Err(PROJECT_STAGE_ERROR.to_string()); } + let parent = project_parent(target); + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if !metadata_is_safe_project_directory(&parent_metadata) { + return Err(PROJECT_STAGE_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() || staged.sync_all().is_err() { @@ -193,7 +223,10 @@ mod tests { publish_new_project_file(&target, content).expect("new project should publish safely"); - assert_eq!(fs::read(&target).expect("published project should be readable"), content); + 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()) @@ -212,8 +245,14 @@ mod tests { let error = publish_new_project_file(&target, br#"{\"id\":\"replacement\"}"#) .expect_err("existing project must not be overwritten unsafely"); - assert_eq!(error, "Project file already exists. Choose a new file name."); - assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("known-good project should remain"), + known_good + ); fs::remove_dir_all(root).expect("test directory should be removable"); } @@ -228,7 +267,12 @@ mod tests { assert_eq!(error, "Could not stage the project safely."); assert!(!target.exists()); - assert_eq!(fs::read_dir(&root).expect("directory should be readable").count(), 0); + assert_eq!( + fs::read_dir(&root) + .expect("directory should be readable") + .count(), + 0 + ); fs::remove_dir_all(root).expect("test directory should be removable"); } @@ -254,7 +298,8 @@ mod tests { 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"); + 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) @@ -270,9 +315,13 @@ mod tests { 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"); + 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)?; From f08dd97bde79edcc18e119a2464010c366a6e62d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:34:50 -0700 Subject: [PATCH 101/448] docs(project): record save-parent trust boundary --- CHANGELOG.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb50b290..1cd8f0112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 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 also opens reparse points without following them before validation. ## [0.1.3] - 2026-04-29 @@ -58,17 +59,3 @@ - Issue #33: Implemented secure local audio intake and project bootstrap - Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 39272d237a219a3a491be63c9aa11538eff58b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:42:11 -0700 Subject: [PATCH 102/448] test(project): reject symlinked save ancestors --- .../project_persistence_parent_symlink.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index f6de3f8c0..48ee58543 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -42,3 +42,48 @@ fn refuses_to_publish_through_symlinked_parent_directory() { fs::remove_dir_all(root).expect("test fixture should be removable"); } + +#[cfg(unix)] +#[test] +fn refuses_to_publish_when_an_ancestor_directory_is_a_symlink() { + 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-persistence-ancestor-symlink-{}-{nonce}", + std::process::id() + )); + let actual_tree = root.join("actual"); + let actual_parent = actual_tree.join("nested"); + let selected_tree = root.join("selected"); + let linked_ancestor = selected_tree.join("redirect"); + fs::create_dir_all(&actual_parent).expect("actual fixture tree should be created"); + fs::create_dir_all(&selected_tree).expect("selected fixture tree should be created"); + symlink(&actual_tree, &linked_ancestor).expect("fixture ancestor symlink should be created"); + + let target = linked_ancestor.join("nested").join("setlist.bscope"); + let error = project_persistence::publish_new_project_file( + &target, + br#"{\"id\":\"ancestor-symlink\"}"#, + ) + .expect_err("a symlinked save ancestor must be rejected before staging"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!actual_parent.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&actual_parent) + .expect("actual fixture directory should remain readable") + .count(), + 0, + "the selected path must not publish through a symlinked ancestor" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} From 2f6afc9e7a846c1de26ce79a507fc4226aab90e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:44:41 -0700 Subject: [PATCH 103/448] fix(project): validate save parent chain --- apps/desktop/src-tauri/src/project_persistence.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 37fc34928..03437b4d5 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -101,6 +101,16 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +fn parent_chain_is_safe(parent: &Path) -> bool { + parent + .ancestors() + .filter(|path| !path.as_os_str().is_empty()) + .all(|path| { + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata_is_safe_project_directory(&metadata)) + }) +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -160,9 +170,7 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< } let parent = project_parent(target); - let parent_metadata = - fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; - if !metadata_is_safe_project_directory(&parent_metadata) { + if !parent_chain_is_safe(parent) { return Err(PROJECT_STAGE_ERROR.to_string()); } From 49c002ca41217dd22d1155b21d86c766d1efd161 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:45:52 -0700 Subject: [PATCH 104/448] test(project): keep portable parent-link boundary --- .../project_persistence_parent_symlink.rs | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index 48ee58543..f6de3f8c0 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -42,48 +42,3 @@ fn refuses_to_publish_through_symlinked_parent_directory() { fs::remove_dir_all(root).expect("test fixture should be removable"); } - -#[cfg(unix)] -#[test] -fn refuses_to_publish_when_an_ancestor_directory_is_a_symlink() { - 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-persistence-ancestor-symlink-{}-{nonce}", - std::process::id() - )); - let actual_tree = root.join("actual"); - let actual_parent = actual_tree.join("nested"); - let selected_tree = root.join("selected"); - let linked_ancestor = selected_tree.join("redirect"); - fs::create_dir_all(&actual_parent).expect("actual fixture tree should be created"); - fs::create_dir_all(&selected_tree).expect("selected fixture tree should be created"); - symlink(&actual_tree, &linked_ancestor).expect("fixture ancestor symlink should be created"); - - let target = linked_ancestor.join("nested").join("setlist.bscope"); - let error = project_persistence::publish_new_project_file( - &target, - br#"{\"id\":\"ancestor-symlink\"}"#, - ) - .expect_err("a symlinked save ancestor must be rejected before staging"); - - assert_eq!(error, "Could not stage the project safely."); - assert!(!actual_parent.join("setlist.bscope").exists()); - assert_eq!( - fs::read_dir(&actual_parent) - .expect("actual fixture directory should remain readable") - .count(), - 0, - "the selected path must not publish through a symlinked ancestor" - ); - - fs::remove_dir_all(root).expect("test fixture should be removable"); -} From 2e48e59916c8fb4552e26c26c8a6c65946f6b7fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:46:52 -0700 Subject: [PATCH 105/448] fix(project): preserve portable parent validation --- apps/desktop/src-tauri/src/project_persistence.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 03437b4d5..37fc34928 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -101,16 +101,6 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } -fn parent_chain_is_safe(parent: &Path) -> bool { - parent - .ancestors() - .filter(|path| !path.as_os_str().is_empty()) - .all(|path| { - fs::symlink_metadata(path) - .is_ok_and(|metadata| metadata_is_safe_project_directory(&metadata)) - }) -} - fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -170,7 +160,9 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< } let parent = project_parent(target); - if !parent_chain_is_safe(parent) { + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if !metadata_is_safe_project_directory(&parent_metadata) { return Err(PROJECT_STAGE_ERROR.to_string()); } From 0c991df26abaef4abc21a245ce041bc91ed421db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:16:07 -0700 Subject: [PATCH 106/448] docs(changelog): preserve protected release history --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd8f0112..815c784b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- 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. @@ -59,3 +64,17 @@ - Issue #33: Implemented secure local audio intake and project bootstrap - Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From c2cc5bbeda6628fa9999401d6b0d228cb9b6bb9c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 13:41:01 +0900 Subject: [PATCH 107/448] fix(audio): preflight source metadata before decode --- CHANGELOG.md | 1 + docs/architecture/overview.md | 1 + docs/doctoring/audio-resource-policy.md | 7 +- docs/security/app-security.md | 1 + .../src/bandscope_analysis/__init__.py | 1 - .../src/bandscope_analysis/audio_metadata.py | 44 +++++++++ .../audio_resource_policy.py | 75 +++++++++++++++ .../separation/audio_separator.py | 2 + .../bandscope_analysis/temporal/analyzer.py | 2 + .../bandscope_analysis/transcription/api.py | 19 +++- .../tests/test_audio_metadata.py | 93 +++++++++++++++++++ .../tests/test_audio_resource_policy.py | 48 ++++++++++ .../test_audio_resource_policy_integration.py | 78 ++++++++++++++++ .../analysis-engine/tests/test_separation.py | 8 ++ .../analysis-engine/tests/test_temporal.py | 10 +- .../tests/test_temporal_error_privacy.py | 4 +- .../tests/test_transcription.py | 29 ++++++ .../analysis-engine/tests/test_youtube.py | 30 +++--- ...outube_downloaded_duration_revalidation.py | 8 +- 19 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 services/analysis-engine/src/bandscope_analysis/audio_metadata.py create mode 100644 services/analysis-engine/tests/test_audio_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ed41299..d45201760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed. - Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. - Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. - Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..b342805a5 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -37,6 +37,7 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code - treat files, URLs, models, caches, and release artifacts as untrusted inputs - route orchestration through typed Tauri IPC and a narrow Python subprocess bridge before considering any loopback HTTP surface - bootstrap local audio projects by validating the selected file in Rust, then passing only typed source metadata through the orchestration boundary +- before Python decoders transform source audio, preflight the already-open container handle through the shared `audio_resource_policy` source-rate/channel/duration contract, then rewind it for decoding - keep project and temp/cache bootstrap roots under Tauri-resolved app-owned directories rather than the shared OS temp namespace ## CI/CD and release flow diff --git a/docs/doctoring/audio-resource-policy.md b/docs/doctoring/audio-resource-policy.md index 3957d42a5..f87d98fe0 100644 --- a/docs/doctoring/audio-resource-policy.md +++ b/docs/doctoring/audio-resource-policy.md @@ -4,7 +4,7 @@ This note records the external evidence used by BandScope's versioned local-audio resource policy. It is implementation doctoring, not a claim that BandScope is certified against any external standard. -The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly 44.1 kHz and within both the accepted sample count and decoded-buffer byte budget before beat tracking or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Before post-download duration/size checks, cleanup, or success metadata can use the yt-dlp result, the completed path is canonicalized and required to remain strictly beneath the current import `out_dir`; a foreign or escaped path fails closed without being deleted. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. +The current Python policy accepts at most 100 MiB of encoded local-audio input and at most 900 seconds of mono decoded audio at 44.1 kHz, which is 39,690,000 samples. The canonical decoded NumPy buffer is additionally bounded to 317,520,000 bytes (39,690,000 samples × 8 bytes), so a decoder cannot stay under the sample ceiling while expanding the admitted in-memory artifact beyond the policy's explicit mono-buffer budget. Request metadata is an early rejection hint only: the decoder boundary still checks the opened descriptor's actual size. Before any `librosa.load(..., sr=..., mono=True, duration=...)` transformation, `soundfile.info` inspects the already-open source handle and the canonical policy rejects malformed headers, source rates below 8 kHz or above 192 kHz, source channel counts outside mono/stereo, and source duration beyond the path's limit; a successful probe rewinds the same handle. Decoder calls request one sample beyond the accepted duration and the returned waveform is then validated as a one-dimensional, non-empty, finite floating-point array at exactly the target rate and within both the accepted sample count and decoded-buffer byte budget before beat tracking, transcription, or Demucs inference. Policy construction also rejects byte, memory, rate, duration, or derived sample-count limits that cannot be represented within the host's bounded integer/sample-count model, so extreme integer configuration cannot escape through Python-to-float conversion overflow. YouTube import uses the same encoded-byte ceiling: yt-dlp `max_filesize`, a progress hook that aborts once `downloaded_bytes` / `total_bytes` / `total_bytes_estimate` exceed 100 MiB, a pre-download reject on announced `filesize` / `filesize_approx`, and a post-download `AudioResourcePolicy` check that deletes the artifact. Before post-download duration/size checks, cleanup, or success metadata can use the yt-dlp result, the completed path is canonicalized and required to remain strictly beneath the current import `out_dir`; a foreign or escaped path fails closed without being deleted. Native local-file and YouTube bootstrap then re-check the filesystem-observed length before storing project state. ## Evidence-to-control mapping @@ -13,16 +13,19 @@ The current Python policy accepts at most 100 MiB of encoded local-audio input a | CWE-770 recommends explicit minimum/maximum resource expectations and limiting resources reachable by unprivileged actors. | `AudioResourcePolicy` makes encoded bytes, decoded samples, decoded mono-buffer bytes, sample rate, numeric dtype, shape, finiteness, and checked limit arithmetic explicit fail-closed invariants. CWE-770 is the more specific mapping-friendly weakness beneath the broader CWE-400 resource-consumption class. | | OWASP ASVS v5.0.0-5.1.1 requires file-handling documentation to define accepted types/extensions and maximum size; v5.0.0-5.2.1 requires accepting only file sizes that can be processed without performance loss or denial of service. | BandScope documents and enforces a finite encoded-byte ceiling before decode, while retaining authoritative descriptor checks at the actual file boundary. ASVS targets web applications/services, so BandScope uses these requirements as security-engineering guidance rather than claiming ASVS conformance for the desktop product. | | librosa 0.11.0 documents `load(..., duration=...)` as loading only up to the requested duration and returning an ndarray plus the resulting sample rate. | Temporal analysis and stem separation request `max_duration + one sample` as a probe, then reject any returned waveform whose exact decoded sample count or in-memory byte size exceeds the accepted limits. The post-decode check remains authoritative because a duration argument alone is not treated as proof of resource-policy compliance. | +| python-soundfile 0.13.1 documents `soundfile.info(file)` as returning container information, including sample rate, channels, duration, and frame count, without reading the decoded waveform. | `audio_metadata.preflight_audio_metadata` uses the already-open handle for source metadata admission, applies the shared rate/channel/duration policy, and rewinds the handle before `librosa.load`; parser and rewind failures become the canonical payload-free policy error. | | yt-dlp documents `max_filesize` as a FileDownloader parameter that skips files larger than the configured byte count, and `progress_hooks` as callbacks that receive `downloaded_bytes`, `total_bytes`, and `total_bytes_estimate`. | YouTube import sets `max_filesize` to `DEFAULT_MAX_ENCODED_FILE_BYTES`, aborts from the progress hook when those byte fields exceed the ceiling, validates that the completed path remains inside the per-import output directory, and still revalidates the written file with the canonical policy so a missing or lying size announcement cannot bypass admission. | ## Residual risk and follow-up -This policy now bounds Python decode/model entry by decoded sample count and decoded mono-buffer memory, and bounds native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The completed-path containment check is a point-in-time canonical path check and does not claim descriptor/handle-level race freedom if a privileged local actor replaces filesystem entries after validation. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is source channel/rate metadata contracts, explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. +This policy now bounds Python source-container admission, decode/model entry by decoded sample count and decoded mono-buffer memory, and native local-file bootstrap plus YouTube download/bootstrap encoded-byte admission. In-flight abort also deletes owned `.part`, `.ytdl`, and `-Frag*` siblings that stay inside that import's output directory; paths that escape the directory are ignored. The completed-path containment check is a point-in-time canonical path check and does not claim descriptor/handle-level race freedom if a privileged local actor replaces filesystem entries after validation. The decoded-memory limit covers the admitted canonical NumPy audio artifact only; it does not claim to bound downstream temporary arrays, PyTorch tensors, model weights, or accelerator allocations. Remaining #781 work is explicit per-job CPU/GPU/VRAM admission budgets, cancellation/resource measurements, and whole-product CPU/GPU parity evidence. Do not treat a post-download-only size check as sufficient: in-flight abort and owned-partial deletion must stay in place so an unknown-size transfer cannot fill the cache root. ## References librosa development team. (2025). *librosa.load (librosa 0.11.0)* [Documentation]. https://librosa.org/doc/0.11.0/generated/librosa.load.html +python-soundfile contributors. (2025). *python-soundfile 0.13.1: `soundfile.info`* [Documentation]. https://python-soundfile.readthedocs.io/en/latest/ + MITRE Corporation. (2026, April 30). *CWE-770: Allocation of resources without limits or throttling (Version 4.20).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/770.html OWASP Foundation. (2025, May). *OWASP Application Security Verification Standard 5.0.0.* https://github.com/OWASP/ASVS/tree/v5.0.0_release/5.0 diff --git a/docs/security/app-security.md b/docs/security/app-security.md index 0bc942986..bd50f0a00 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -138,6 +138,7 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Prefer isolated worker processing for decode and analysis. - Guard against very large files, abnormal duration, and hostile metadata. - Apply the versioned canonical local-audio resource policy consistently at request preflight and again at the opened-file/decoded-waveform boundary; request metadata is never authoritative for actual resource use. +- Before any decoder resamples, downmixes, or duration-truncates local audio, inspect source-container metadata from the already-open handle with `soundfile.info`, enforce the shared 8 kHz–192 kHz and mono/stereo source contract, reject overlong sources, and rewind the handle before `librosa.load`. - In the Python analysis boundary, reject decoded audio that is empty, non-finite, wrong-rate, wrong-shaped, or over the accepted sample budget before beat tracking or model inference. Use the one-sample-over decode probe described in `docs/doctoring/audio-resource-policy.md` so an exact-boundary track remains accepted while excess decoded output is observable and fails closed. - Do not add arbitrary filesystem scanning just to find media files. - When bootstrapping a project around local audio, prefer referencing the validated original file plus app-owned temp/cache/project roots over copying the file until persistence requirements justify the extra storage boundary. diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py index 0cf11033b..ce4beb801 100644 --- a/services/analysis-engine/src/bandscope_analysis/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/__init__.py @@ -5,7 +5,6 @@ from .health import build_health_report - _STEM_SAFE_FAILURE_LOG_MESSAGES = frozenset( { "Stem separation failed because the source file was missing.", diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py new file mode 100644 index 000000000..9f5874d8e --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -0,0 +1,44 @@ +"""Bounded source-container metadata preflight for local audio decoders. + +Security Notes: +- The selected audio bytes and container headers are untrusted. +- This module reads metadata from an already-open caller-owned handle only; it + does not open paths, decode PCM, follow URLs, or allocate a waveform. +- Malformed headers, unsupported source rates/channels, and overlong sources + fail closed with the payload-free canonical policy error. +- A successful probe rewinds the handle so the downstream decoder receives the + same source from its beginning. +""" + +from __future__ import annotations + +from typing import BinaryIO + +import soundfile # type: ignore[import-untyped] # soundfile has no py.typed marker. + +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, +) + +_POLICY_ERROR = "Audio input violates the audio resource policy." + + +def preflight_audio_metadata( + fileobj: BinaryIO, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> None: + """Validate source metadata without decoding PCM and rewind the handle.""" + try: + fileobj.seek(0) + info = soundfile.info(fileobj) + fileobj.seek(0) + policy.validate_source_metadata( + frames=info.frames, + sample_rate=info.samplerate, + channels=info.channels, + ) + except ValueError: + raise + except Exception as error: + raise ValueError(_POLICY_ERROR) from error diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 65d08c0b5..63e7194b6 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -32,6 +32,10 @@ AUDIO_RESOURCE_POLICY_VERSION = "1" DEFAULT_TARGET_SAMPLE_RATE = 44_100 +DEFAULT_MIN_SOURCE_SAMPLE_RATE = 8_000 +DEFAULT_MAX_SOURCE_SAMPLE_RATE = 192_000 +DEFAULT_MIN_SOURCE_CHANNELS = 1 +DEFAULT_MAX_SOURCE_CHANNELS = 2 DEFAULT_MAX_ENCODED_FILE_BYTES = 100 * 1024 * 1024 DEFAULT_MAX_DURATION_SECONDS = 15 * 60 DEFAULT_MAX_DECODED_AUDIO_BYTES = ( @@ -52,12 +56,24 @@ class AudioResourcePolicy: ceiling at ``target_sample_rate``. max_decoded_audio_bytes: Maximum in-memory byte size of the canonical decoded mono NumPy buffer. + min_source_sample_rate: Minimum source-container sample rate accepted + before resampling. + max_source_sample_rate: Maximum source-container sample rate accepted + before resampling. + min_source_channels: Minimum source-container channel count accepted + before downmixing. + max_source_channels: Maximum source-container channel count accepted + before downmixing. """ max_encoded_file_bytes: int = DEFAULT_MAX_ENCODED_FILE_BYTES target_sample_rate: int = DEFAULT_TARGET_SAMPLE_RATE max_duration_seconds: float = float(DEFAULT_MAX_DURATION_SECONDS) max_decoded_audio_bytes: int = DEFAULT_MAX_DECODED_AUDIO_BYTES + min_source_sample_rate: int = DEFAULT_MIN_SOURCE_SAMPLE_RATE + max_source_sample_rate: int = DEFAULT_MAX_SOURCE_SAMPLE_RATE + min_source_channels: int = DEFAULT_MIN_SOURCE_CHANNELS + max_source_channels: int = DEFAULT_MAX_SOURCE_CHANNELS def __post_init__(self) -> None: """Reject invalid policy configuration before it can weaken admission.""" @@ -86,6 +102,24 @@ def __post_init__(self) -> None: or self.max_decoded_audio_bytes > sys.maxsize - 1 ): raise ValueError(_POLICY_ERROR) + for source_bound in ( + self.min_source_sample_rate, + self.max_source_sample_rate, + self.min_source_channels, + self.max_source_channels, + ): + if ( + isinstance(source_bound, bool) + or not isinstance(source_bound, int) + or source_bound <= 0 + or source_bound > sys.maxsize - 1 + ): + raise ValueError(_POLICY_ERROR) + if ( + self.min_source_sample_rate > self.max_source_sample_rate + or self.min_source_channels > self.max_source_channels + ): + raise ValueError(_POLICY_ERROR) try: duration_seconds = float(self.max_duration_seconds) except (OverflowError, ValueError): @@ -131,6 +165,43 @@ def validate_encoded_file_bytes(self, file_size: object) -> int: raise ValueError(_POLICY_ERROR) return file_size + def validate_source_metadata( + self, + frames: object, + sample_rate: object, + channels: object, + ) -> None: + """Validate source-container metadata before any decode transformation. + + Args: + frames: Number of source frames reported by the container parser. + sample_rate: Source sample rate in Hz before resampling. + channels: Source channel count before downmixing. + + Raises: + ValueError: If metadata is malformed or outside the source bounds. + """ + if ( + isinstance(frames, bool) + or not isinstance(frames, int) + or frames <= 0 + or isinstance(sample_rate, bool) + or not isinstance(sample_rate, int) + or sample_rate < self.min_source_sample_rate + or sample_rate > self.max_source_sample_rate + or isinstance(channels, bool) + or not isinstance(channels, int) + or channels < self.min_source_channels + or channels > self.max_source_channels + ): + raise ValueError(_POLICY_ERROR) + try: + source_duration_seconds = float(frames) / float(sample_rate) + except (OverflowError, ValueError): + raise ValueError(_POLICY_ERROR) from None + if source_duration_seconds > float(self.max_duration_seconds): + raise ValueError(_POLICY_ERROR) + def validate_decoded_audio( self, audio: object, @@ -180,5 +251,9 @@ def validate_decoded_audio( "DEFAULT_MAX_DECODED_AUDIO_BYTES", "DEFAULT_MAX_DURATION_SECONDS", "DEFAULT_MAX_ENCODED_FILE_BYTES", + "DEFAULT_MAX_SOURCE_CHANNELS", + "DEFAULT_MAX_SOURCE_SAMPLE_RATE", + "DEFAULT_MIN_SOURCE_CHANNELS", + "DEFAULT_MIN_SOURCE_SAMPLE_RATE", "DEFAULT_TARGET_SAMPLE_RATE", ] diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 666451af9..095050c1c 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -38,6 +38,7 @@ import librosa import numpy as np +from bandscope_analysis.audio_metadata import preflight_audio_metadata from bandscope_analysis.audio_resource_policy import ( DEFAULT_MAX_DURATION_SECONDS, AudioResourcePolicy, @@ -222,6 +223,7 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: self.resource_policy.validate_encoded_file_bytes(file_size) except ValueError as error: raise ValueError("Audio file is too large for stem separation") from error + preflight_audio_metadata(fileobj, self.resource_policy) with warnings.catch_warnings(): warnings.filterwarnings( diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index bbedd53e9..110222fa8 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -12,6 +12,7 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_metadata import preflight_audio_metadata from bandscope_analysis.audio_resource_policy import ( DEFAULT_AUDIO_RESOURCE_POLICY, DEFAULT_MAX_DURATION_SECONDS, @@ -123,6 +124,7 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: self.resource_policy.validate_encoded_file_bytes(file_size) except ValueError as error: raise ValueError("Audio file is too large for temporal analysis") from error + preflight_audio_metadata(fileobj, self.resource_policy) with warnings.catch_warnings(): warnings.filterwarnings( diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index f2a732d31..b4090269f 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -10,6 +10,9 @@ import numpy as np from numpy.typing import NDArray +from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import AudioResourcePolicy + TARGET_SR = 22050 MAX_STEM_BYTES = 50 * 1024 * 1024 MAX_TRANSCRIPTION_DURATION_SECONDS = 120 @@ -17,6 +20,12 @@ HOP_LENGTH = 512 MIN_NOTE_DURATION_SECONDS = 0.05 MIN_SIGNAL_PEAK = 1e-5 +TRANSCRIPTION_RESOURCE_POLICY = AudioResourcePolicy( + max_encoded_file_bytes=MAX_STEM_BYTES, + target_sample_rate=TARGET_SR, + max_duration_seconds=MAX_TRANSCRIPTION_DURATION_SECONDS, + max_decoded_audio_bytes=(TARGET_SR * MAX_TRANSCRIPTION_DURATION_SECONDS + 1) * 8, +) @dataclass @@ -42,16 +51,20 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: if len(stem_data) > MAX_STEM_BYTES: raise ValueError("Stem data is too large for transcription.") + source = io.BytesIO(stem_data) + preflight_audio_metadata(source, TRANSCRIPTION_RESOURCE_POLICY) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") y, sr = librosa.load( - io.BytesIO(stem_data), + source, sr=TARGET_SR, mono=True, - duration=MAX_TRANSCRIPTION_DURATION_SECONDS, + duration=TRANSCRIPTION_RESOURCE_POLICY.decode_probe_duration_seconds, ) - y_array = np.asarray(y, dtype=np.float32) + y_array = np.asarray( + TRANSCRIPTION_RESOURCE_POLICY.validate_decoded_audio(y, sr), dtype=np.float32 + ) if y_array.size == 0 or float(np.max(np.abs(y_array))) < MIN_SIGNAL_PEAK: return [] diff --git a/services/analysis-engine/tests/test_audio_metadata.py b/services/analysis-engine/tests/test_audio_metadata.py new file mode 100644 index 000000000..4c9b5c0ca --- /dev/null +++ b/services/analysis-engine/tests/test_audio_metadata.py @@ -0,0 +1,93 @@ +"""Source-container metadata preflight regressions.""" + +from __future__ import annotations + +import io +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from bandscope_analysis.audio_metadata import preflight_audio_metadata + + +def _info(*, frames: int = 44_100, samplerate: int = 44_100, channels: int = 2) -> SimpleNamespace: + """Build the metadata subset consumed by the preflight boundary.""" + return SimpleNamespace(frames=frames, samplerate=samplerate, channels=channels) + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_accepts_metadata_and_rewinds_the_caller_handle(mock_info: object) -> None: + """A successful metadata probe leaves the decoder handle at its beginning.""" + source = io.BytesIO(b"header-bytes") + + def inspect(handle: io.BytesIO) -> SimpleNamespace: + """Consume a small header before returning parsed metadata.""" + handle.read(3) + return _info() + + mock_info.side_effect = inspect # type: ignore[attr-defined] + + preflight_audio_metadata(source) + + assert source.tell() == 0 + + +@pytest.mark.parametrize( + ("info", "reason"), + [ + (_info(frames=44_100 * 901), "audio resource policy"), + (_info(samplerate=7_999), "audio resource policy"), + (_info(channels=3), "audio resource policy"), + ], +) +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_rejects_untrusted_source_metadata( + mock_info: object, + info: SimpleNamespace, + reason: str, +) -> None: + """Source duration, rate, and channel bounds fail before PCM decode.""" + mock_info.return_value = info # type: ignore[attr-defined] + + with pytest.raises(ValueError, match=reason): + preflight_audio_metadata(io.BytesIO(b"header")) + + +@patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=RuntimeError("decoder detail"), +) +def test_preflight_maps_parser_failures_to_payload_free_policy_error(_mock_info: object) -> None: + """Container parser failures do not leak decoder details.""" + with pytest.raises(ValueError, match="audio resource policy") as error: + preflight_audio_metadata(io.BytesIO(b"bad-header")) + + assert "decoder detail" not in str(error.value) + + +@patch("bandscope_analysis.audio_metadata.soundfile.info") +def test_preflight_maps_rewind_failures_to_payload_free_policy_error(mock_info: object) -> None: + """A handle that cannot rewind after probing cannot reach a decoder.""" + + class SeekFailsAfterProbe(io.BytesIO): + """Fail only when the metadata boundary tries to rewind the handle.""" + + def __init__(self) -> None: + """Initialize the caller-owned byte handle and seek counter.""" + super().__init__(b"header") + self.seek_count = 0 + + def seek(self, *args: object, **kwargs: object) -> int: + """Reject the second seek, which is the post-probe rewind.""" + self.seek_count += 1 + if self.seek_count == 2: + raise OSError("rewind failed") + return super().seek(*args, **kwargs) + + mock_info.return_value = _info() # type: ignore[attr-defined] + + with pytest.raises(ValueError, match="audio resource policy") as error: + preflight_audio_metadata(SeekFailsAfterProbe()) + + assert "rewind failed" not in str(error.value) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index 9789b8f02..2d52f44a5 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -8,6 +8,10 @@ from bandscope_analysis.audio_resource_policy import ( AUDIO_RESOURCE_POLICY_VERSION, DEFAULT_AUDIO_RESOURCE_POLICY, + DEFAULT_MAX_SOURCE_CHANNELS, + DEFAULT_MAX_SOURCE_SAMPLE_RATE, + DEFAULT_MIN_SOURCE_CHANNELS, + DEFAULT_MIN_SOURCE_SAMPLE_RATE, AudioResourcePolicy, ) @@ -38,6 +42,46 @@ def test_encoded_file_size_accepts_exact_boundary() -> None: assert policy.validate_encoded_file_bytes(100) == 100 +def test_source_metadata_accepts_the_published_bounds() -> None: + """Source metadata accepts the inclusive rate, channel, and duration bounds.""" + policy = AudioResourcePolicy(max_duration_seconds=15 * 60) + + policy.validate_source_metadata( + frames=DEFAULT_MAX_SOURCE_SAMPLE_RATE * 15 * 60, + sample_rate=DEFAULT_MAX_SOURCE_SAMPLE_RATE, + channels=DEFAULT_MAX_SOURCE_CHANNELS, + ) + policy.validate_source_metadata( + frames=DEFAULT_MIN_SOURCE_SAMPLE_RATE, + sample_rate=DEFAULT_MIN_SOURCE_SAMPLE_RATE, + channels=DEFAULT_MIN_SOURCE_CHANNELS, + ) + + +@pytest.mark.parametrize( + ("frames", "sample_rate", "channels"), + [ + (DEFAULT_MAX_SOURCE_SAMPLE_RATE * (15 * 60 + 1), 44_100, 2), + (44_100, DEFAULT_MIN_SOURCE_SAMPLE_RATE - 1, 2), + (44_100, DEFAULT_MAX_SOURCE_SAMPLE_RATE + 1, 2), + (44_100, 44_100, DEFAULT_MAX_SOURCE_CHANNELS + 1), + (44_100, 44_100, DEFAULT_MIN_SOURCE_CHANNELS - 1), + (0, 44_100, 2), + (44_100, True, 2), + (44_100, 44_100, True), + (10**400, 44_100, 2), + ], +) +def test_source_metadata_fails_closed_before_decode( + frames: object, + sample_rate: object, + channels: object, +) -> None: + """Overlong and malformed source metadata cannot reach a decoder.""" + with pytest.raises(ValueError, match="audio resource policy"): + DEFAULT_AUDIO_RESOURCE_POLICY.validate_source_metadata(frames, sample_rate, channels) + + @pytest.mark.parametrize( ("audio", "sample_rate"), [ @@ -105,6 +149,10 @@ def test_decoded_audio_accepts_exact_sample_boundary() -> None: {"max_duration_seconds": float("inf")}, {"max_decoded_audio_bytes": 0}, {"max_decoded_audio_bytes": True}, + {"min_source_sample_rate": 0}, + {"max_source_channels": True}, + {"min_source_sample_rate": 48_000, "max_source_sample_rate": 44_100}, + {"min_source_channels": 2, "max_source_channels": 1}, ], ) def test_policy_configuration_itself_fails_closed(kwargs: dict[str, object]) -> None: diff --git a/services/analysis-engine/tests/test_audio_resource_policy_integration.py b/services/analysis-engine/tests/test_audio_resource_policy_integration.py index ec44a93d3..1f58720f1 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy_integration.py +++ b/services/analysis-engine/tests/test_audio_resource_policy_integration.py @@ -2,6 +2,9 @@ from __future__ import annotations +from types import SimpleNamespace +from unittest.mock import Mock + import numpy as np import pytest @@ -71,6 +74,10 @@ def test_temporal_decoder_probes_one_sample_past_duration_limit_and_fails_closed ) source = tmp_path / "overlong.wav" source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) captured: dict[str, object] = {} def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: @@ -96,6 +103,37 @@ def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: assert captured["mono"] is True +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=44_100 * 901, samplerate=44_100, channels=2), + SimpleNamespace(frames=44_100, samplerate=7_999, channels=2), + SimpleNamespace(frames=44_100, samplerate=44_100, channels=3), + ], +) +def test_temporal_rejects_source_metadata_before_librosa_decode( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Temporal analysis must inspect source metadata before resampling or truncation.""" + import librosa + + source = tmp_path / "source-metadata.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(librosa, "load", load_mock) + + with pytest.raises(ValueError, match="audio resource policy"): + TemporalAnalyzer().analyze(source) + + load_mock.assert_not_called() + + def test_stem_decoder_probes_one_sample_past_duration_limit_and_fails_closed( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -110,6 +148,10 @@ def test_stem_decoder_probes_one_sample_past_duration_limit_and_fails_closed( ) source = tmp_path / "overlong.wav" source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) captured: dict[str, object] = {} def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: @@ -133,6 +175,38 @@ def fake_load(fileobj: object, **kwargs: object) -> tuple[np.ndarray, int]: assert captured["mono"] is True +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=44_100 * 901, samplerate=44_100, channels=2), + SimpleNamespace(frames=44_100, samplerate=7_999, channels=2), + SimpleNamespace(frames=44_100, samplerate=44_100, channels=3), + ], +) +def test_stem_decoder_rejects_source_metadata_before_librosa_decode( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Stem separation must inspect source metadata before mono conversion or model work.""" + import librosa + + source = tmp_path / "source-metadata.wav" + source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(librosa, "load", load_mock) + + separator = AudioStemSeparator(AudioSeparationConfig(max_file_bytes=100)) + with pytest.raises(ValueError, match="audio resource policy"): + separator.separate(source) + + load_mock.assert_not_called() + + def test_stem_decoder_rejects_nonfinite_decoded_output_before_model( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -142,6 +216,10 @@ def test_stem_decoder_rejects_nonfinite_decoded_output_before_model( source = tmp_path / "nonfinite.wav" source.write_bytes(b"bounded") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) monkeypatch.setattr( librosa, "load", diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index f8e098521..649fb0f23 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -465,6 +465,10 @@ def test_audio_stem_separator_rejects_empty_decoder_output( """Ensure empty decoder output fails safely.""" audio_path = tmp_path / "empty.wav" audio_path.write_bytes(b"placeholder") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) monkeypatch.setattr( "bandscope_analysis.separation.audio_separator.librosa.load", lambda *args, **kwargs: (np.array([], dtype=np.float32), 8_000), @@ -481,6 +485,10 @@ def test_audio_stem_separator_redacts_decoder_exceptions( """Ensure decoder failures are surfaced without full local paths.""" audio_path = tmp_path / "broken.wav" audio_path.write_bytes(b"placeholder") + monkeypatch.setattr( + "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + lambda *_args, **_kwargs: None, + ) def fail_decode(*args, **kwargs): raise RuntimeError(f"decoder failed under {tmp_path}") diff --git a/services/analysis-engine/tests/test_temporal.py b/services/analysis-engine/tests/test_temporal.py index b6fdbb017..16c7f7034 100644 --- a/services/analysis-engine/tests/test_temporal.py +++ b/services/analysis-engine/tests/test_temporal.py @@ -94,7 +94,7 @@ def fake_load(*args, **kwargs): monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) with pytest.raises(ValueError, match="Expected numpy array"): TemporalAnalyzer().analyze(test_wav) @@ -115,7 +115,7 @@ def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: monkeypatch.setattr(librosa, "load", fake_load) test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) with pytest.raises(ValueError, match=r"^Temporal analysis failed\.$") as exc_info: TemporalAnalyzer().analyze(test_wav) @@ -129,7 +129,7 @@ def test_temporal_analyzer_rejects_oversized_file(monkeypatch, tmp_path: Path) - from bandscope_analysis.temporal import analyzer as analyzer_module test_wav = tmp_path / "large.wav" - test_wav.write_bytes(b"1234") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) monkeypatch.setattr(analyzer_module, "MAX_AUDIO_FILE_BYTES", 1) @@ -148,7 +148,7 @@ def test_temporal_analyzer_uses_duration_limit(monkeypatch, tmp_path: Path) -> N import librosa test_wav = tmp_path / "bounded.wav" - test_wav.write_bytes(b"1234") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) captured_kwargs: dict[str, object] = {} def fake_load(path, **kwargs): @@ -179,7 +179,7 @@ def test_temporal_analyzer_does_not_suppress_unrelated_loader_warnings( import librosa test_wav = tmp_path / "test.wav" - test_wav.write_bytes(b"dummy") + sf.write(test_wav, np.zeros(4_000, dtype=np.float32), 44_100) def fake_load(*args: object, **kwargs: object) -> tuple[np.ndarray, int]: warnings.warn("unrelated downstream warning", FutureWarning, stacklevel=2) diff --git a/services/analysis-engine/tests/test_temporal_error_privacy.py b/services/analysis-engine/tests/test_temporal_error_privacy.py index bb849c791..ea0c6519f 100644 --- a/services/analysis-engine/tests/test_temporal_error_privacy.py +++ b/services/analysis-engine/tests/test_temporal_error_privacy.py @@ -5,7 +5,9 @@ import logging from pathlib import Path +import numpy as np import pytest +import soundfile as sf from bandscope_analysis.temporal import TemporalAnalyzer @@ -33,7 +35,7 @@ def test_decoder_failure_redacts_source_path_and_decoder_payload( sensitive_path = tmp_path / "private-customer-session" / "unreleased-song.wav" sensitive_path.parent.mkdir() - sensitive_path.write_bytes(b"bounded-test-input") + sf.write(sensitive_path, np.zeros(4_000, dtype=np.float32), 44_100) decoder_payload = "decoder exposed /private/customer/token-shaped-audio-name.wav" def fail_decode(*args: object, **kwargs: object) -> tuple[object, int]: diff --git a/services/analysis-engine/tests/test_transcription.py b/services/analysis-engine/tests/test_transcription.py index f9b55af93..80eb126ff 100644 --- a/services/analysis-engine/tests/test_transcription.py +++ b/services/analysis-engine/tests/test_transcription.py @@ -4,8 +4,11 @@ import io from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import Mock import numpy as np +import pytest import soundfile as sf from bandscope_analysis.transcription import api as transcription_api @@ -62,6 +65,32 @@ def test_transcribe_bass_stem_rejects_oversized_input(monkeypatch) -> None: transcribe_bass_stem(b"abc") +@pytest.mark.parametrize( + "metadata", + [ + SimpleNamespace(frames=22050 * 121, samplerate=22050, channels=2), + SimpleNamespace(frames=22050, samplerate=7_999, channels=2), + SimpleNamespace(frames=22050, samplerate=22050, channels=3), + ], +) +def test_transcribe_bass_stem_rejects_source_metadata_before_decode( + monkeypatch: pytest.MonkeyPatch, + metadata: SimpleNamespace, +) -> None: + """Bass transcription must validate source duration, rate, and channels before librosa.""" + monkeypatch.setattr( + "bandscope_analysis.audio_metadata.soundfile.info", + lambda _fileobj: metadata, + ) + load_mock = Mock(side_effect=AssertionError("source metadata must be checked first")) + monkeypatch.setattr(transcription_api.librosa, "load", load_mock) + + with pytest.raises(ValueError, match="audio resource policy"): + transcribe_bass_stem(b"not-a-real-wav") + + load_mock.assert_not_called() + + def test_transcribe_bass_stem_wraps_pitch_tracking_parameter_errors(monkeypatch) -> None: """Return a stable ValueError when pYIN rejects decoded audio parameters.""" stem_data = _render_bass_sequence([ExpectedNote("E2", 0.0, 0.45)]) diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index f7b0f863c..0ae449aa9 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -102,19 +102,20 @@ def test_download_youtube_audio_success( "filesize": True, "filesize_approx": float("nan"), } + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = mock_info - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.webm" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.webm" mock_exists.return_value = True mock_getsize.return_value = 10 * 1024 * 1024 input_url = "https://youtube.com/watch?v=abc123DEF45" - result = download_youtube_audio(input_url, "/tmp") + result = download_youtube_audio(input_url, out_dir) assert result["ok"] is True assert result["metadata"]["id"] == "abc123DEF45" assert result["metadata"]["title"] == "Test Video" assert result["metadata"]["duration"] == 60 - assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.webm" + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.webm" # Assert that YoutubeDL was initialized with the correct options mock_ydl_class.assert_called_once() @@ -159,21 +160,22 @@ def test_download_youtube_audio_converted_extension( "title": "Test Video", "duration": 60, } + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = mock_info - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.webm" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.webm" # os.path.exists returns False for .webm, but True for the converted .opus. def exists_side_effect(path: str) -> bool: """Mock exists function to simulate converted extension file presence.""" - return path == "/tmp/abc123DEF45.opus" + return path == f"{out_dir}/abc123DEF45.opus" mock_exists.side_effect = exists_side_effect mock_getsize.return_value = 10 * 1024 * 1024 - result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) assert result["ok"] is True - assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.opus" + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.opus" @patch("bandscope_analysis.youtube.os.path.exists") @@ -298,15 +300,16 @@ def test_download_youtube_audio_accepts_size_between_legacy_and_canonical_ceilin """A 60 MiB download that the old 50 MB check rejected is now accepted.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" mock_exists.return_value = True mock_getsize.return_value = 60 * 1024 * 1024 - result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) assert result["ok"] is True - assert result["metadata"]["filepath"] == "/tmp/abc123DEF45.m4a" + assert result["metadata"]["filepath"] == f"{out_dir}/abc123DEF45.m4a" @patch("bandscope_analysis.youtube.os.path.getsize") @@ -343,16 +346,17 @@ def test_download_youtube_audio_size_exceeded( """Post-download files one byte over the canonical 100 MiB ceiling are deleted.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.return_value = {"id": "abc123DEF45", "duration": 10 * 60} - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" mock_exists.return_value = True mock_getsize.return_value = DEFAULT_MAX_ENCODED_FILE_BYTES + 1 - result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) assert result["ok"] is False assert result["error"]["code"] == "size_exceeded" assert result["error"]["message"] == YOUTUBE_SIZE_EXCEEDED_MESSAGE - mock_remove.assert_called_with("/tmp/abc123DEF45.m4a") + mock_remove.assert_called_with(f"{out_dir}/abc123DEF45.m4a") @patch("bandscope_analysis.youtube.os.path.getsize") diff --git a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py index 2999a3308..6780203f6 100644 --- a/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py +++ b/services/analysis-engine/tests/test_youtube_downloaded_duration_revalidation.py @@ -1,5 +1,6 @@ """Post-download YouTube duration revalidation regressions.""" +from pathlib import Path from unittest.mock import MagicMock, patch from bandscope_analysis.youtube import download_youtube_audio @@ -18,15 +19,16 @@ def test_youtube_revalidates_downloaded_duration_before_returning_success( """Changed download metadata must not bypass the 15-minute admission limit.""" mock_ydl = MagicMock() mock_ydl_class.return_value.__enter__.return_value = mock_ydl + out_dir = str(Path("/tmp").resolve()) mock_ydl.extract_info.side_effect = [ {"id": "abc123DEF45", "duration": 60}, {"id": "abc123DEF45", "title": "Changed metadata", "duration": 16 * 60}, ] - mock_ydl.prepare_filename.return_value = "/tmp/abc123DEF45.m4a" + mock_ydl.prepare_filename.return_value = f"{out_dir}/abc123DEF45.m4a" mock_exists.return_value = True mock_isfile.return_value = True - result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", "/tmp") + result = download_youtube_audio("https://youtube.com/watch?v=abc123DEF45", out_dir) assert result == { "ok": False, @@ -35,4 +37,4 @@ def test_youtube_revalidates_downloaded_duration_before_returning_success( "message": "Video exceeds the 15-minute limit.", }, } - mock_remove.assert_called_once_with("/tmp/abc123DEF45.m4a") + mock_remove.assert_called_once_with(f"{out_dir}/abc123DEF45.m4a") From bb4a827d705490aacedf26da141fff1a8da96067 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:12:17 -0700 Subject: [PATCH 108/448] test(project): reproduce symlink swap at handle acquisition --- .../project_persistence_open_authority.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_open_authority.rs 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..29f06c471 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -0,0 +1,43 @@ +#[cfg(unix)] +mod project_persistence { + include!("../src/project_persistence.rs"); + + pub(crate) fn open_for_authority_test( + target: &std::path::Path, + ) -> std::io::Result { + open_project_file(target) + } +} + +#[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_for_authority_test(&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"); +} From bae6de7f74d7387361ebe003ef0e3e4322eb86ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:14:51 -0700 Subject: [PATCH 109/448] fix(project): refuse final symlink at Unix open boundary --- .../src-tauri/src/project_persistence.rs | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 37fc34928..93e284d2e 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -16,6 +16,10 @@ const PROJECT_TOO_LARGE_ERROR: &str = "Project file is too large (exceeds 5MB li 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() { @@ -50,9 +54,34 @@ fn open_project_file(target: &Path) -> std::io::Result { options.open(target) } -#[cfg(not(windows))] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn open_project_file(target: &Path) -> std::io::Result { - File::open(target) + 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")) +))] +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)))] +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)] @@ -135,12 +164,13 @@ where /// 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. Unix builds compare device/inode identity. Windows opens the reparse -/// point itself rather than following it and rejects reparse handles, then requires the stable file -/// metadata revision to match around the open. This closes the selected-path swap between the -/// preflight and handle acquisition without adding a dependency or granting JavaScript path -/// authority. The reader remains capped at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, and -/// recovery semantics remain later #962 work. +/// 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 the reparse +/// point itself rather than following it and rejects reparse handles, then requires stable file +/// metadata around acquisition. Other Unix targets fail closed until their no-follow flags are +/// explicitly modeled. The reader remains capped at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, +/// and recovery semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } From d86bc28d1653dee9ad7c916e00dd356dd3befe53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:16:19 -0700 Subject: [PATCH 110/448] test(project): expose crate-local opener to regression harness --- apps/desktop/src-tauri/src/project_persistence.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 93e284d2e..53dce7259 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -44,7 +44,7 @@ fn remove_stage(path: &Path) { } #[cfg(windows)] -fn open_project_file(target: &Path) -> std::io::Result { +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; let mut options = fs::OpenOptions::new(); @@ -55,7 +55,7 @@ fn open_project_file(target: &Path) -> std::io::Result { } #[cfg(any(target_os = "linux", target_os = "macos"))] -fn open_project_file(target: &Path) -> std::io::Result { +pub(crate) fn open_project_file(target: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; let mut options = fs::OpenOptions::new(); @@ -65,11 +65,8 @@ fn open_project_file(target: &Path) -> std::io::Result { options.open(target) } -#[cfg(all( - unix, - not(any(target_os = "linux", target_os = "macos")) -))] -fn open_project_file(_target: &Path) -> std::io::Result { +#[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", @@ -77,7 +74,7 @@ fn open_project_file(_target: &Path) -> std::io::Result { } #[cfg(not(any(unix, windows)))] -fn open_project_file(_target: &Path) -> std::io::Result { +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", From 5197f4f83413ffdf4aa9fa9a76885e8da19020cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:16:55 -0700 Subject: [PATCH 111/448] test(project): exercise Unix opener without source inclusion --- .../tests/project_persistence_open_authority.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index 29f06c471..69b63a837 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -1,13 +1,5 @@ -#[cfg(unix)] -mod project_persistence { - include!("../src/project_persistence.rs"); - - pub(crate) fn open_for_authority_test( - target: &std::path::Path, - ) -> std::io::Result { - open_project_file(target) - } -} +#[path = "../src/project_persistence.rs"] +mod project_persistence; #[cfg(unix)] #[test] @@ -33,7 +25,7 @@ fn unix_project_opener_refuses_symlink_at_handle_acquisition() { .expect("external fixture should be written"); symlink(&external, &selected).expect("fixture symlink should be created"); - let opened = project_persistence::open_for_authority_test(&selected); + let opened = project_persistence::open_project_file(&selected); assert!( opened.is_err(), From 53280f84d53bf7e434817c4bb777481e71135351 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:17:49 -0700 Subject: [PATCH 112/448] docs(project): record no-follow load acquisition --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 815c784b3..9f1560c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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 also opens reparse points without following them before validation. +- 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. ## [0.1.3] - 2026-04-29 From d70a2521d4af137406b22130ec4f232963b24603 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:04:25 -0700 Subject: [PATCH 113/448] test(project): reproduce confirmed overwrite regression --- .../tests/project_persistence_overwrite.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_overwrite.rs 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..adfb35d2e --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -0,0 +1,45 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-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"); +} From f27ccfa8b57b99719ef6b2d09e584bbcd2a7fcc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:10:33 -0700 Subject: [PATCH 114/448] fix(project): stage before replacing confirmed saves --- .../src-tauri/src/project_persistence.rs | 82 +++++++++++++++---- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 53dce7259..ba82c1d3c 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -172,15 +172,16 @@ pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } -/// Publishes one new project only after its complete bounded bytes are staged and synced. +/// Publishes a selected project only after its complete bounded bytes are staged and synced. /// -/// This helper deliberately does not implement overwrite semantics. The directly selected parent -/// must itself be a real directory rather than a symlink/reparse point before any staging artifact is -/// created. `File::create_new` makes the staging name non-clobbering, and `hard_link` atomically -/// creates the user-selected destination only if that destination is still absent. An existing file -/// or dangling symlink therefore stays untouched instead of being truncated before replacement bytes -/// are durable. Crash-safe overwrite, ancestor-handle binding, parent-directory durability, backup -/// rotation, migration, and recovery remain separate project-format work under #962. +/// The directly selected parent must itself be a real directory rather than a symlink/reparse point +/// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new +/// destination is published with `hard_link`, preserving the no-clobber contract if another writer +/// creates that name first. If the save dialog selected an existing regular file, the synced staging +/// file is atomically renamed over that directory entry; symlink/reparse/special targets fail closed. +/// This avoids truncating the known-good destination before replacement bytes are durable. Ancestor- +/// handle binding, parent-directory durability, concurrent-writer serialization, backup rotation, +/// migration, and recovery remain separate project-format work under #962. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { return Err(PROJECT_STAGE_ERROR.to_string()); @@ -202,6 +203,29 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< } drop(staged); + let existing_target = match fs::symlink_metadata(target) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + }; + + if existing_target { + if fs::rename(&stage, target).is_err() { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + return Ok(()); + } + if let Err(error) = fs::hard_link(&stage, target) { remove_stage(&stage); return if error.kind() == std::io::ErrorKind::AlreadyExists { @@ -263,23 +287,49 @@ mod tests { } #[test] - fn refuses_to_clobber_an_existing_known_good_project() { - let root = test_dir("existing"); + 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, br#"{\"id\":\"replacement\"}"#) - .expect_err("existing project must not be overwritten unsafely"); + 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!( - error, - "Project file already exists. Choose a new file name." + 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(&target).expect("known-good project should remain"), + 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"); } @@ -383,7 +433,7 @@ mod tests { assert!( main_source.contains("project_persistence::publish_new_project_file"), - "the Tauri save command must use the staged non-clobbering publisher" + "the Tauri save command must use the staged project publisher" ); assert!( !main_source.contains("std::fs::write(path, content)"), From 2578579c37443542ba9fbbea5e6879140fe2b4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:19:20 -0700 Subject: [PATCH 115/448] test(project): reproduce hard-link-only save regression --- .../tests/project_persistence_overwrite.rs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index adfb35d2e..494185bea 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -2,7 +2,7 @@ mod project_persistence; use std::{ - fs, + fs, io, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; @@ -43,3 +43,56 @@ fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { fs::remove_dir_all(root).expect("test directory should be removable"); } + +#[test] +fn new_project_falls_back_to_exclusive_create_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 must still support a first save"); + + assert_eq!( + fs::read(&target).expect("fallback project should be readable"), + content + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn hard_link_fallback_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::Unsupported, + "fixture filesystem has no hard links", + )) + }, + ) + .expect_err("fallback 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"); +} From 0f3d2de54cac3ba4f1135cabb04082663b8bf72b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:24:15 -0700 Subject: [PATCH 116/448] fix(project): fall back when hard links are unavailable --- .../src-tauri/src/project_persistence.rs | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index ba82c1d3c..8588e9475 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -172,17 +172,52 @@ pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } +fn write_first_project_exclusively(target: &Path, content: &[u8]) -> Result<(), String> { + let mut published = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(target) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + Err(_) => return Err(PROJECT_PUBLISH_ERROR.to_string()), + }; + + if published.write_all(content).is_err() || published.sync_all().is_err() { + // Do not remove `target` by path after publication begins: another actor could replace the + // directory entry between this handle write and cleanup. There was no previous known-good + // target in this fallback path, so fail without introducing a path-based delete race. + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + Ok(()) +} + /// Publishes a selected project only after its complete bounded bytes are staged and synced. /// /// The directly selected parent must itself be a real directory rather than a symlink/reparse point /// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new -/// destination is published with `hard_link`, preserving the no-clobber contract if another writer -/// creates that name first. If the save dialog selected an existing regular file, the synced staging -/// file is atomically renamed over that directory entry; symlink/reparse/special targets fail closed. -/// This avoids truncating the known-good destination before replacement bytes are durable. Ancestor- -/// handle binding, parent-directory durability, concurrent-writer serialization, backup rotation, -/// migration, and recovery remain separate project-format work under #962. +/// destination first uses a hard link to the synced staging inode, preserving no-clobber publication +/// where hard links are available. Filesystems without hard-link support fall back to an exclusive +/// `create_new` target and a second bounded write, which remains race-safe against another writer but +/// is not claimed to provide atomic first-save visibility. If the save dialog selected an existing +/// regular file, the synced staging file is atomically renamed over that directory entry; +/// symlink/reparse/special targets fail closed. Ancestor-handle binding, parent-directory durability, +/// concurrent-writer serialization, backup rotation, migration, and recovery remain separate +/// project-format work under #962. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + publish_new_project_file_with_linker(target, content, fs::hard_link) +} + +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<()>, +{ if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { return Err(PROJECT_STAGE_ERROR.to_string()); } @@ -226,13 +261,14 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< return Ok(()); } - if let Err(error) = fs::hard_link(&stage, target) { + if let Err(error) = link(&stage, target) { + if error.kind() == std::io::ErrorKind::AlreadyExists { + remove_stage(&stage); + return Err(PROJECT_EXISTS_ERROR.to_string()); + } + let fallback = write_first_project_exclusively(target, content); remove_stage(&stage); - return if error.kind() == std::io::ErrorKind::AlreadyExists { - Err(PROJECT_EXISTS_ERROR.to_string()) - } else { - Err(PROJECT_PUBLISH_ERROR.to_string()) - }; + return fallback; } // Both names reference the already-synced inode at this point. Cleanup failure does not make the From 987ca7b32ea282163ed484df622ab3bc7194b200 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 06:14:44 +0900 Subject: [PATCH 117/448] fix(project): report oversized save input --- .../src-tauri/src/project_persistence.rs | 48 ++++++++++--------- .../project_persistence_open_authority.rs | 3 +- .../tests/project_persistence_overwrite.rs | 11 ++++- .../project_persistence_parent_symlink.rs | 8 ++-- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 8588e9475..a4e519830 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -59,9 +59,7 @@ 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.read(true).custom_flags(UNIX_PROJECT_OPEN_FLAGS); options.open(target) } @@ -137,7 +135,9 @@ where } let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; - let opened = file.metadata().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) @@ -207,7 +207,9 @@ fn write_first_project_exclusively(target: &Path, content: &[u8]) -> Result<(), /// concurrent-writer serialization, backup rotation, migration, and recovery remain separate /// project-format work under #962. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { - publish_new_project_file_with_linker(target, content, fs::hard_link) + publish_new_project_file_with_linker(target, content, |source, destination| { + fs::hard_link(source, destination) + }) } pub(crate) fn publish_new_project_file_with_linker( @@ -218,9 +220,12 @@ pub(crate) fn publish_new_project_file_with_linker( where F: FnOnce(&Path, &Path) -> std::io::Result<()>, { - if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { + 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); let parent_metadata = @@ -316,7 +321,11 @@ mod tests { ); let names = fs::read_dir(&root) .expect("test directory should be readable") - .map(|entry| entry.expect("directory entry should be readable").file_name()) + .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"); @@ -360,12 +369,10 @@ mod tests { 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() - ); + 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"); } @@ -378,7 +385,7 @@ mod tests { let error = publish_new_project_file(&target, &content) .expect_err("oversized project should fail before publication"); - assert_eq!(error, "Could not stage the project safely."); + assert_eq!(error, "Project file is too large (exceeds 5MB limit)"); assert!(!target.exists()); assert_eq!( fs::read_dir(&root) @@ -411,8 +418,7 @@ mod tests { 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"); + 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) @@ -428,13 +434,9 @@ mod tests { 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"); + 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)?; diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index 69b63a837..d2cabe3ae 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -21,8 +21,7 @@ fn unix_project_opener_refuses_symlink_at_handle_acquisition() { 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"); + 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); diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 494185bea..5d038343e 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -37,7 +37,11 @@ fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { ); let names = fs::read_dir(&root) .expect("test directory should be readable") - .map(|entry| entry.expect("directory entry should be readable").file_name()) + .map(|entry| { + entry + .expect("directory entry should be readable") + .file_name() + }) .collect::>(); assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); @@ -89,7 +93,10 @@ fn hard_link_fallback_never_clobbers_a_target_that_appears_concurrently() { ) .expect_err("fallback must fail closed when another writer wins the target name"); - assert_eq!(error, "Project file already exists. Choose a new file 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 diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index f6de3f8c0..c2b203630 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -24,11 +24,9 @@ fn refuses_to_publish_through_symlinked_parent_directory() { 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"); + 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()); From 0bab0e8fbb80b3acd83773e0dddf636570df3ecc Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 08:02:54 +0900 Subject: [PATCH 118/448] fix(project): keep staging names within filesystem limits --- .../src-tauri/src/project_persistence.rs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a4e519830..7e9d70f49 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1,5 +1,4 @@ use std::{ - ffi::OsString, fs::{self, File}, io::{Read, Write}, path::{Path, PathBuf}, @@ -30,12 +29,10 @@ fn project_parent(target: &Path) -> &Path { fn staging_path(target: &Path) -> Result { let parent = project_parent(target); - let file_name = target - .file_name() - .ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; - let mut stage_name = OsString::from("."); - stage_name.push(file_name); - stage_name.push(format!(".{}.stage", uuid::Uuid::new_v4())); + 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)) } @@ -331,6 +328,18 @@ mod tests { 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"); + } + #[test] fn invalid_replacement_does_not_clobber_an_existing_known_good_project() { let root = test_dir("existing-invalid"); From 54d8966ee6734f7d6305c8bf7d503c1721a37840 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 08:52:02 +0900 Subject: [PATCH 119/448] fix(project): fail closed without atomic first-save support --- CHANGELOG.md | 3 +- .../src-tauri/src/project_persistence.rs | 33 +++---------------- .../tests/project_persistence_overwrite.rs | 20 +++++------ 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0372bd751..f0777fe97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - 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 also opens reparse points without following them before validation. - 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. +- Fail closed on first-save filesystems without hard-link support instead of directly writing a destination that could remain partial after a disk or sync failure. ## [0.1.3] - 2026-04-29 @@ -79,4 +80,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 7e9d70f49..29be298d5 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -169,37 +169,15 @@ pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } -fn write_first_project_exclusively(target: &Path, content: &[u8]) -> Result<(), String> { - let mut published = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(target) - { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - return Err(PROJECT_EXISTS_ERROR.to_string()); - } - Err(_) => return Err(PROJECT_PUBLISH_ERROR.to_string()), - }; - - if published.write_all(content).is_err() || published.sync_all().is_err() { - // Do not remove `target` by path after publication begins: another actor could replace the - // directory entry between this handle write and cleanup. There was no previous known-good - // target in this fallback path, so fail without introducing a path-based delete race. - return Err(PROJECT_PUBLISH_ERROR.to_string()); - } - Ok(()) -} - /// Publishes a selected project only after its complete bounded bytes are staged and synced. /// /// The directly selected parent must itself be a real directory rather than a symlink/reparse point /// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new /// destination first uses a hard link to the synced staging inode, preserving no-clobber publication -/// where hard links are available. Filesystems without hard-link support fall back to an exclusive -/// `create_new` target and a second bounded write, which remains race-safe against another writer but -/// is not claimed to provide atomic first-save visibility. If the save dialog selected an existing -/// regular file, the synced staging file is atomically renamed over that directory entry; +/// where hard links are available. Filesystems without hard-link support fail closed because a +/// direct first-save write could leave a partial destination after a disk or sync failure. If the +/// save dialog selected an existing regular file, the synced staging file is atomically renamed over +/// that directory entry; /// symlink/reparse/special targets fail closed. Ancestor-handle binding, parent-directory durability, /// concurrent-writer serialization, backup rotation, migration, and recovery remain separate /// project-format work under #962. @@ -268,9 +246,8 @@ where remove_stage(&stage); return Err(PROJECT_EXISTS_ERROR.to_string()); } - let fallback = write_first_project_exclusively(target, content); remove_stage(&stage); - return fallback; + return Err(PROJECT_PUBLISH_ERROR.to_string()); } // Both names reference the already-synced inode at this point. Cleanup failure does not make the diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 5d038343e..f2c6d2fa3 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -49,12 +49,12 @@ fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { } #[test] -fn new_project_falls_back_to_exclusive_create_when_hard_links_are_unsupported() { +fn new_project_fails_closed_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( + let error = project_persistence::publish_new_project_file_with_linker( &target, content, |_stage, _target| { @@ -64,17 +64,15 @@ fn new_project_falls_back_to_exclusive_create_when_hard_links_are_unsupported() )) }, ) - .expect("a filesystem without hard links must still support a first save"); + .expect_err("a filesystem without hard links must fail before direct publication"); - assert_eq!( - fs::read(&target).expect("fallback project should be readable"), - content - ); + assert_eq!(error, "Could not publish the project safely."); + assert!(!target.exists()); fs::remove_dir_all(root).expect("test directory should be removable"); } #[test] -fn hard_link_fallback_never_clobbers_a_target_that_appears_concurrently() { +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\"}"#; @@ -86,12 +84,12 @@ fn hard_link_fallback_never_clobbers_a_target_that_appears_concurrently() { |_stage, target| { fs::write(target, racer)?; Err(io::Error::new( - io::ErrorKind::Unsupported, - "fixture filesystem has no hard links", + io::ErrorKind::AlreadyExists, + "racer won the target name", )) }, ) - .expect_err("fallback must fail closed when another writer wins the target name"); + .expect_err("publication must fail closed when another writer wins the target name"); assert_eq!( error, From eddbb0291bfaf4300950f5148136c8c3ae3e49d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:10:26 -0700 Subject: [PATCH 120/448] test(project): cover hard-link-free atomic publication --- .../tests/project_persistence_overwrite.rs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index f2c6d2fa3..7d3d962ce 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -49,12 +49,12 @@ fn confirmed_existing_project_is_replaced_after_new_bytes_are_staged() { } #[test] -fn new_project_fails_closed_when_hard_links_are_unsupported() { +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\"}"#; - let error = project_persistence::publish_new_project_file_with_linker( + project_persistence::publish_new_project_file_with_linker( &target, content, |_stage, _target| { @@ -64,10 +64,21 @@ fn new_project_fails_closed_when_hard_links_are_unsupported() { )) }, ) - .expect_err("a filesystem without hard links must fail before direct publication"); + .expect("a filesystem without hard links should publish through the reserved rename fallback"); - assert_eq!(error, "Could not publish the project safely."); - assert!(!target.exists()); + 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"); } From 73af787eb6ce09ddbde0f102b0da73fdc12fee62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:12:49 -0700 Subject: [PATCH 121/448] test(project): protect fallback reservation race --- .../tests/project_persistence_overwrite.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 7d3d962ce..8a993d601 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -82,6 +82,37 @@ fn new_project_uses_reserved_rename_when_hard_links_are_unsupported() { 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"); From 1f658a60c27a17d7778104dead36bede6dec307d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:13:34 -0700 Subject: [PATCH 122/448] fix(project): publish safely without hard links --- .../src-tauri/src/project_persistence.rs | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 29be298d5..a389d7ce7 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -173,14 +173,14 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// /// The directly selected parent must itself be a real directory rather than a symlink/reparse point /// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new -/// destination first uses a hard link to the synced staging inode, preserving no-clobber publication -/// where hard links are available. Filesystems without hard-link support fail closed because a -/// direct first-save write could leave a partial destination after a disk or sync failure. If the -/// save dialog selected an existing regular file, the synced staging file is atomically renamed over -/// that directory entry; -/// symlink/reparse/special targets fail closed. Ancestor-handle binding, parent-directory durability, -/// concurrent-writer serialization, backup rotation, migration, and recovery remain separate -/// project-format work under #962. +/// destination first uses a hard link to the synced staging inode. When that filesystem does not +/// support hard links, the publisher reserves the still-absent destination with `File::create_new` +/// and atomically renames the fully synced stage over that reservation. `AlreadyExists` at either +/// publication boundary fails closed without clobbering the competing file. If the save dialog +/// selected an existing regular file, the synced staging file is atomically renamed over that +/// directory entry; symlink/reparse/special targets fail closed. Ancestor-handle binding, +/// parent-directory durability, concurrent-writer serialization, backup rotation, migration, and +/// recovery remain separate project-format work under #962. 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) @@ -246,8 +246,25 @@ where remove_stage(&stage); return Err(PROJECT_EXISTS_ERROR.to_string()); } - remove_stage(&stage); - return Err(PROJECT_PUBLISH_ERROR.to_string()); + + let reserved = match File::create_new(target) { + Ok(file) => file, + Err(reserve_error) if reserve_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()); + } + }; + drop(reserved); + + if fs::rename(&stage, target).is_err() { + remove_stage(&stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + return Ok(()); } // Both names reference the already-synced inode at this point. Cleanup failure does not make the From 106da93f5daaca3d086c9c1004e89f23ff99c5b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:15:58 -0700 Subject: [PATCH 123/448] test(project): require native Windows file identity --- .../project_persistence_windows_identity.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_windows_identity.rs 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"); +} From 723b34b35a0644f8e08d9a3f344302a7d6acce09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:17:05 -0700 Subject: [PATCH 124/448] fix(project): bind Windows reads to native file identity --- .../src-tauri/src/project_persistence.rs | 117 +++++++++++++++--- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a389d7ce7..047ca6cad 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -84,18 +84,60 @@ fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { } #[cfg(windows)] -fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { - use std::os::windows::fs::MetadataExt; +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} - left.file_attributes() == right.file_attributes() - && left.creation_time() == right.creation_time() - && left.last_write_time() == right.last_write_time() - && left.file_size() == right.file_size() +#[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(not(any(unix, windows)))] -fn same_file_identity(_left: &fs::Metadata, _right: &fs::Metadata) -> bool { - false +#[cfg(windows)] +#[derive(Debug, Eq, PartialEq)] +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)] @@ -131,19 +173,56 @@ where 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) - || !same_file_identity(&before, &opened) - || !same_file_identity(&opened, &after) - { + 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_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); reader @@ -160,11 +239,11 @@ where /// 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 the reparse -/// point itself rather than following it and rejects reparse handles, then requires stable file -/// metadata around acquisition. Other Unix targets fail closed until their no-follow flags are -/// explicitly modeled. The reader remains capped at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, -/// and recovery semantics remain later #962 work. +/// 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, migration, and recovery semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } From abb39bf7bd4f9ee6fc88bdd8db80d9a4a8113545 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 17:17:51 -0700 Subject: [PATCH 125/448] docs(project): describe portable native-safe persistence --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0777fe97..6d45c5fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ - 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 also opens reparse points without following them before validation. +- 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. -- Fail closed on first-save filesystems without hard-link support instead of directly writing a destination that could remain partial after a disk or sync failure. +- Preserve first-save crash safety on filesystems without hard-link support by reserving the destination without clobbering and atomically renaming the fully synced staged project into place. ## [0.1.3] - 2026-04-29 @@ -80,4 +80,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From 505a595d481f8ba03abd8d13e7c17202918c833f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 15:51:45 +0900 Subject: [PATCH 126/448] fix(chords): handle zero-element layouts --- CHANGELOG.md | 1 + .../src/bandscope_analysis/chords/chord_recognizer.py | 2 +- services/analysis-engine/tests/test_chord_recognizer.py | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6500c23e3..926405947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. - Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. - Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. +- Treat every zero-element NumPy layout as empty chord input, including shapes whose first dimension is non-zero, before feature extraction. ### Changed diff --git a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py index 8f6466924..2d414afc8 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/chord_recognizer.py @@ -396,7 +396,7 @@ def recognize(self, y: np.ndarray, sr: int = 22050) -> list[TrackedChord]: Returns: List of TrackedChord dicts with start_time, end_time, chord, and confidence. """ - if len(y) == 0: + if y.size == 0: return [] y_harmonic = self._separate_harmonic(y) diff --git a/services/analysis-engine/tests/test_chord_recognizer.py b/services/analysis-engine/tests/test_chord_recognizer.py index 20a6dcf78..88ff6684c 100644 --- a/services/analysis-engine/tests/test_chord_recognizer.py +++ b/services/analysis-engine/tests/test_chord_recognizer.py @@ -3,6 +3,7 @@ from unittest.mock import patch import numpy as np +import pytest from bandscope_analysis.chords.chord_recognizer import ( ChordRecognizer, @@ -20,6 +21,14 @@ def test_chord_recognizer_empty_audio() -> None: assert result == [] +@pytest.mark.parametrize("shape", [(0, 2), (2, 0)]) +def test_chord_recognizer_empty_layouts(shape: tuple[int, int]) -> None: + """Every zero-element NumPy layout must short-circuit recognition.""" + recognizer = ChordRecognizer() + + assert recognizer.recognize(np.empty(shape), sr=22050) == [] + + def test_chord_recognizer_unvoiced_audio() -> None: """Test chord recognition with noise.""" recognizer = ChordRecognizer() From 4eddf73b61484170bfe19aedde9be8235c8a5d23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:09:19 -0700 Subject: [PATCH 127/448] test(project): reproduce final-path reservation crash window --- .../tests/project_persistence_atomic_publication.rs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs 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..d61f8bc09 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs @@ -0,0 +1,9 @@ +#[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)"), + "hard-link fallback must not materialize an empty final-path placeholder before the staged project is atomically published" + ); +} From 16c68adc1975ae1b94ffa1adf8da8d57b7dd228c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:10:59 -0700 Subject: [PATCH 128/448] fix(project): publish first saves with native no-replace rename --- .../src-tauri/src/project_persistence.rs | 199 ++++++++++++++++-- 1 file changed, 183 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 047ca6cad..f5c33a4b8 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -40,6 +40,132 @@ 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(windows)] pub(crate) fn open_project_file(target: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; @@ -253,13 +379,15 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// The directly selected parent must itself be a real directory rather than a symlink/reparse point /// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new /// destination first uses a hard link to the synced staging inode. When that filesystem does not -/// support hard links, the publisher reserves the still-absent destination with `File::create_new` -/// and atomically renames the fully synced stage over that reservation. `AlreadyExists` at either -/// publication boundary fails closed without clobbering the competing file. If the save dialog +/// support hard links, Linux uses `renameat2(RENAME_NOREPLACE)`, macOS uses +/// `renamex_np(RENAME_EXCL)`, and Windows uses `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so +/// the fully synced staging file becomes the final name without first materializing an empty final +/// path. An existing destination at either no-clobber boundary fails closed. If the save dialog /// selected an existing regular file, the synced staging file is atomically renamed over that -/// directory entry; symlink/reparse/special targets fail closed. Ancestor-handle binding, -/// parent-directory durability, concurrent-writer serialization, backup rotation, migration, and -/// recovery remain separate project-format work under #962. +/// directory entry; symlink/reparse/special targets fail closed. Filesystems that do not support the +/// native no-replace primitive fail closed rather than falling back to reserve-then-replace. +/// Ancestor-handle binding, parent-directory durability, concurrent-writer serialization, backup +/// rotation, migration, and recovery remain separate project-format work under #962. 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) @@ -326,9 +454,9 @@ where return Err(PROJECT_EXISTS_ERROR.to_string()); } - let reserved = match File::create_new(target) { - Ok(file) => file, - Err(reserve_error) if reserve_error.kind() == std::io::ErrorKind::AlreadyExists => { + match rename_noreplace(&stage, target) { + Ok(()) => return Ok(()), + Err(publish_error) if publish_error.kind() == std::io::ErrorKind::AlreadyExists => { remove_stage(&stage); return Err(PROJECT_EXISTS_ERROR.to_string()); } @@ -336,14 +464,7 @@ where remove_stage(&stage); return Err(PROJECT_PUBLISH_ERROR.to_string()); } - }; - drop(reserved); - - if fs::rename(&stage, target).is_err() { - remove_stage(&stage); - return Err(PROJECT_PUBLISH_ERROR.to_string()); } - return Ok(()); } // Both names reference the already-synced inode at this point. Cleanup failure does not make the @@ -377,6 +498,52 @@ mod tests { 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"); From ed913cc8d6275b69d7f0dd1bbcf22b471a7f3251 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:18:00 -0700 Subject: [PATCH 129/448] docs(project): describe native no-replace publication --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d45c5fcf..65286026b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - 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 reserving the destination without clobbering and atomically renaming the fully synced staged project into place. +- 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. ## [0.1.3] - 2026-04-29 From 6edb558fa914cfc3381d5ee75699fa6795bcb7f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:24:46 -0700 Subject: [PATCH 130/448] test(project): reproduce linked ancestor save redirect --- .../project_persistence_parent_symlink.rs | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index c2b203630..e4a897450 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -2,22 +2,27 @@ mod project_persistence; #[cfg(unix)] -#[test] -fn refuses_to_publish_through_symlinked_parent_directory() { - use std::{ - fs, - os::unix::fs::symlink, - time::{SystemTime, UNIX_EPOCH}, - }; +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-parent-symlink-{}-{nonce}", + "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"); @@ -40,3 +45,33 @@ fn refuses_to_publish_through_symlinked_parent_directory() { 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"); +} From ba1ce2b885adfaddcd0b9571a3fcec6ebd6e985e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:07:29 -0700 Subject: [PATCH 131/448] fix(project): reject linked save ancestors --- .../src-tauri/src/project_persistence.rs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index f5c33a4b8..d621fd104 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -290,6 +290,16 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +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)) + }) +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -376,18 +386,20 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// Publishes a selected project only after its complete bounded bytes are staged and synced. /// -/// The directly selected parent must itself be a real directory rather than a symlink/reparse point -/// before any staging artifact is created. `File::create_new` makes staging non-clobbering. A new -/// destination first uses a hard link to the synced staging inode. When that filesystem does not -/// support hard links, Linux uses `renameat2(RENAME_NOREPLACE)`, macOS uses +/// The selected parent and each lexical ancestor must be a real directory rather than a +/// symlink/reparse point before any staging artifact is created. This rejects static ancestor-link +/// redirection without following the link into another authority boundary. `File::create_new` makes +/// staging non-clobbering. A new destination first uses a hard link to the synced staging inode. When +/// that filesystem does not support hard links, Linux uses `renameat2(RENAME_NOREPLACE)`, macOS uses /// `renamex_np(RENAME_EXCL)`, and Windows uses `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so /// the fully synced staging file becomes the final name without first materializing an empty final /// path. An existing destination at either no-clobber boundary fails closed. If the save dialog /// selected an existing regular file, the synced staging file is atomically renamed over that /// directory entry; symlink/reparse/special targets fail closed. Filesystems that do not support the -/// native no-replace primitive fail closed rather than falling back to reserve-then-replace. -/// Ancestor-handle binding, parent-directory durability, concurrent-writer serialization, backup -/// rotation, migration, and recovery remain separate project-format work under #962. +/// native no-replace primitive fail closed rather than falling back to reserve-then-replace. This +/// ancestor check remains a path-based preflight rather than descriptor-bound protection against a +/// concurrent parent-chain swap. Parent-directory durability, concurrent-writer serialization, +/// backup rotation, migration, and recovery remain separate project-format work under #962. 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) @@ -410,9 +422,7 @@ where } let parent = project_parent(target); - let parent_metadata = - fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; - if !metadata_is_safe_project_directory(&parent_metadata) { + if !project_parent_chain_is_safe(parent) { return Err(PROJECT_STAGE_ERROR.to_string()); } @@ -741,4 +751,4 @@ mod tests { "the Tauri load command must not allocate through an unbounded second read" ); } -} +} \ No newline at end of file From 19697890911588310fcba73ca3e11feb7a00895d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:20:04 -0700 Subject: [PATCH 132/448] fix(project): allow trusted macOS root aliases --- .../src-tauri/src/project_persistence.rs | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index d621fd104..9f1e84b5f 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -290,13 +290,30 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +#[cfg(target_os = "macos")] +fn metadata_is_trusted_macos_root_directory_alias(path: &Path, metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + metadata.file_type().is_symlink() + && metadata.uid() == 0 + && path.parent() == Some(Path::new("/")) + && fs::metadata(path).is_ok_and(|target_metadata| target_metadata.is_dir()) +} + +#[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)) + fs::symlink_metadata(ancestor).is_ok_and(|metadata| { + metadata_is_safe_project_directory(&metadata) + || metadata_is_trusted_macos_root_directory_alias(ancestor, &metadata) + }) }) } @@ -387,19 +404,22 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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. This rejects static ancestor-link -/// redirection without following the link into another authority boundary. `File::create_new` makes -/// staging non-clobbering. A new destination first uses a hard link to the synced staging inode. When -/// that filesystem does not support hard links, Linux uses `renameat2(RENAME_NOREPLACE)`, macOS uses -/// `renamex_np(RENAME_EXCL)`, and Windows uses `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so -/// the fully synced staging file becomes the final name without first materializing an empty final -/// path. An existing destination at either no-clobber boundary fails closed. If the save dialog -/// selected an existing regular file, the synced staging file is atomically renamed over that -/// directory entry; symlink/reparse/special targets fail closed. Filesystems that do not support the -/// native no-replace primitive fail closed rather than falling back to reserve-then-replace. This -/// ancestor check remains a path-based preflight rather than descriptor-bound protection against a -/// concurrent parent-chain swap. Parent-directory durability, concurrent-writer serialization, -/// backup rotation, migration, and recovery remain separate project-format work under #962. +/// symlink/reparse point before any staging artifact is created. On macOS, a root-owned top-level +/// directory symlink whose resolved target is a directory is treated as trusted OS path +/// normalization (for example the system `/var` alias); deeper links 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. A new destination first uses a hard link +/// to the synced staging inode. When that filesystem does not support hard links, Linux uses +/// `renameat2(RENAME_NOREPLACE)`, macOS uses `renamex_np(RENAME_EXCL)`, and Windows uses +/// `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so the fully synced staging file becomes the +/// final name without first materializing an empty final path. An existing destination at either +/// no-clobber boundary fails closed. If the save dialog selected an existing regular file, the synced +/// staging file is atomically renamed over that directory entry; symlink/reparse/special targets fail +/// closed. Filesystems that do not support the native no-replace primitive fail closed rather than +/// falling back to reserve-then-replace. This ancestor check remains a path-based preflight rather +/// than descriptor-bound protection against a concurrent parent-chain swap. Parent-directory +/// durability, concurrent-writer serialization, backup rotation, migration, and recovery remain +/// separate project-format work under #962. 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) From b28a4b89ea1043f519b739c08a4397a1b40f5e17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 05:19:20 -0700 Subject: [PATCH 133/448] test(project): constrain macOS root alias authority Agent: ChatGPT Model: GPT-5.6 Sol --- .../project_persistence_macos_root_alias.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_macos_root_alias.rs 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" + ); +} From 5095e533fe0d7a02f728be0619a3585744e33dac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 05:21:12 -0700 Subject: [PATCH 134/448] fix(project): constrain macOS root aliases Agent: ChatGPT Model: GPT-5.6 Sol --- .../src-tauri/src/project_persistence.rs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 9f1e84b5f..9ac60e74e 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -290,14 +290,30 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +#[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::metadata(path).is_ok_and(|target_metadata| target_metadata.is_dir()) + && 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"))] @@ -404,9 +420,9 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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, a root-owned top-level -/// directory symlink whose resolved target is a directory is treated as trusted OS path -/// normalization (for example the system `/var` alias); deeper links remain fail-closed. This rejects +/// 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. A new destination first uses a hard link /// to the synced staging inode. When that filesystem does not support hard links, Linux uses @@ -725,7 +741,7 @@ mod tests { .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 directory should be removable"); + fs::remove_dir_all(root).expect("test fixture should be removable"); } #[test] @@ -771,4 +787,4 @@ mod tests { "the Tauri load command must not allocate through an unbounded second read" ); } -} \ No newline at end of file +} From f1b486d589f9a6f093a9c09d6e757b305e264f23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 06:17:57 -0700 Subject: [PATCH 135/448] test(project): reject existing-target identity swaps --- .../tests/project_persistence_overwrite.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 8a993d601..9e14bfc79 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -143,3 +143,41 @@ fn new_project_never_clobbers_a_target_that_appears_concurrently() { ); 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"); +} From 3d7b0a4795bde0723aaa8a9cffcf384800e1940e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:12:37 -0700 Subject: [PATCH 136/448] fix(project): reject stale existing-target replacement --- .../src-tauri/src/project_persistence.rs | 294 ++++++++++++++++-- 1 file changed, 261 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 9ac60e74e..ff56cb762 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -166,6 +166,135 @@ fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> )) } +#[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; @@ -290,6 +419,113 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +#[cfg(unix)] +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ProjectFileIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +pub(crate) fn project_file_identity(target: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + + 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(ProjectFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[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"))] +pub(crate) fn replace_existing_project_file( + stage: &Path, + target: &Path, + expected: &ProjectFileIdentity, +) -> Result<(), String> { + let candidate = project_file_identity(stage)?; + if rename_exchange(stage, target).is_err() { + remove_stage(stage); + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(stage); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + remove_stage(stage); + return Ok(()); + } + + let target_is_candidate = + project_file_identity(target).is_ok_and(|identity| identity == candidate); + if target_is_candidate && rename_exchange(stage, target).is_ok() { + remove_stage(stage); + } + 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 = project_file_identity(stage)?; + let backup = staging_path(target)?; + if replace_file_with_backup(target, stage, &backup).is_err() { + return Err(PROJECT_PUBLISH_ERROR.to_string()); + } + + let displaced = project_file_identity(&backup); + if displaced.as_ref().is_ok_and(|identity| identity == expected) { + remove_stage(&backup); + return Ok(()); + } + + 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() { + remove_stage(stage); + } + Err(PROJECT_PUBLISH_ERROR.to_string()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn replace_existing_project_file( + 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()? { @@ -424,18 +660,18 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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. A new destination first uses a hard link -/// to the synced staging inode. When that filesystem does not support hard links, Linux uses -/// `renameat2(RENAME_NOREPLACE)`, macOS uses `renamex_np(RENAME_EXCL)`, and Windows uses -/// `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so the fully synced staging file becomes the -/// final name without first materializing an empty final path. An existing destination at either -/// no-clobber boundary fails closed. If the save dialog selected an existing regular file, the synced -/// staging file is atomically renamed over that directory entry; symlink/reparse/special targets fail -/// closed. Filesystems that do not support the native no-replace primitive fail closed rather than -/// falling back to reserve-then-replace. This ancestor check remains a path-based preflight rather -/// than descriptor-bound protection against a concurrent parent-chain swap. Parent-directory -/// durability, concurrent-writer serialization, backup rotation, migration, and recovery remain -/// separate project-format work under #962. +/// aliases. `File::create_new` makes staging non-clobbering. If the selected target exists, its native +/// identity is captured before staging. 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. Filesystems without the required native +/// primitive fail closed. These checks do not claim descriptor-bound protection for a parent-chain +/// swap, authority before the first post-dialog identity snapshot, or crash recovery if a process is +/// terminated during a mismatch rollback; those remain project-format work under #962. 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) @@ -462,6 +698,17 @@ where 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()); + } + Some(project_file_identity(target)?) + } + 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() || staged.sync_all().is_err() { @@ -471,27 +718,8 @@ where } drop(staged); - let existing_target = match fs::symlink_metadata(target) { - Ok(metadata) => { - if !metadata_is_regular_project_file(&metadata) { - remove_stage(&stage); - return Err(PROJECT_PUBLISH_ERROR.to_string()); - } - true - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(_) => { - remove_stage(&stage); - return Err(PROJECT_PUBLISH_ERROR.to_string()); - } - }; - - if existing_target { - if fs::rename(&stage, target).is_err() { - remove_stage(&stage); - return Err(PROJECT_PUBLISH_ERROR.to_string()); - } - return Ok(()); + if let Some(expected) = expected_target { + return replace_existing_project_file(&stage, target, &expected); } if let Err(error) = link(&stage, target) { From 2880df65c784eb61587ac2b159aca7bc64137d24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:15:41 -0700 Subject: [PATCH 137/448] docs(changelog): record conditional project replacement --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65286026b..3ea62acc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 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. ## [0.1.3] - 2026-04-29 @@ -64,9 +65,9 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #33: Engineered section, form, and cue anchor extraction pipeline - Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph +- Issue #34: Added role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI - Issue #27: Supported manual overrides, provenance tracking, and local project persistence From b9a1a2fbb7d9a479d933de126dbab6471d1e889d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:16:21 -0700 Subject: [PATCH 138/448] docs(changelog): preserve historical entries --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea62acc4..7c950d8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,9 +65,9 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Engineered section, form, and cue anchor extraction pipeline +- Issue #33: Implemented secure local audio intake and project bootstrap - Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Added role extraction targets and part graph +- Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI - Issue #27: Supported manual overrides, provenance tracking, and local project persistence From 2ad48295a74839eabac2650705d9245ffad0d79b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:22:16 -0700 Subject: [PATCH 139/448] test(project): preserve restrictive overwrite permissions --- .../tests/project_persistence_permissions.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_permissions.rs 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..de867530c --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs @@ -0,0 +1,40 @@ +#[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"); +} From d5feefe64b5b336deae68c6d79e4b6fb567d8ce2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:36:33 -0700 Subject: [PATCH 140/448] fix(project): preserve overwrite permissions --- .../src-tauri/src/project_persistence.rs | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index ff56cb762..1a23820be 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -427,17 +427,22 @@ pub(crate) struct ProjectFileIdentity { } #[cfg(unix)] -pub(crate) fn project_file_identity(target: &Path) -> Result { +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(ProjectFileIdentity { - device: metadata.dev(), - inode: metadata.ino(), - }) + Ok(project_file_identity_from_metadata(&metadata)) } #[cfg(windows)] @@ -661,17 +666,19 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// `/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 is captured before staging. 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. Filesystems without the required native -/// primitive fail closed. These checks do not claim descriptor-bound protection for a parent-chain -/// swap, authority before the first post-dialog identity snapshot, or crash recovery if a process is -/// terminated during a mismatch rollback; those remain project-format work under #962. +/// identity and permissions are captured from the same pre-staging metadata snapshot on Unix; the +/// staged inode receives those permissions after its bytes are written and before it is synced. +/// 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. Filesystems without the required native primitive fail closed. These checks do not claim +/// descriptor-bound protection for a parent-chain swap, authority before the first post-dialog identity +/// snapshot, or crash recovery if a process is terminated during a mismatch rollback; those remain +/// project-format work under #962. 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) @@ -703,7 +710,11 @@ where if !metadata_is_regular_project_file(&metadata) { return Err(PROJECT_PUBLISH_ERROR.to_string()); } - Some(project_file_identity(target)?) + #[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()), @@ -711,14 +722,27 @@ where 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() || staged.sync_all().is_err() { + 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() { + if staged.set_permissions(permissions.clone()).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 { + if let Some((expected, _)) = expected_target { return replace_existing_project_file(&stage, target, &expected); } From 9786629f329b3d7d74e213b7ffd134c6f33dc12f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:41:03 -0700 Subject: [PATCH 141/448] test(project): reproduce Windows stage leak on failed replace --- .../tests/project_persistence_overwrite.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 9e14bfc79..6171225ce 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -181,3 +181,45 @@ fn existing_project_never_clobbers_a_target_swapped_after_authority_snapshot() { ); 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"); +} From 702396bd11c473c88e410ef4069bf9a1f0bcfc36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:44:17 -0700 Subject: [PATCH 142/448] fix(project): clean failed Windows replacement stages --- apps/desktop/src-tauri/src/project_persistence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 1a23820be..3c4d40a67 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -504,6 +504,7 @@ pub(crate) fn replace_existing_project_file( let candidate = project_file_identity(stage)?; let backup = staging_path(target)?; if replace_file_with_backup(target, stage, &backup).is_err() { + remove_stage(stage); return Err(PROJECT_PUBLISH_ERROR.to_string()); } From 578f94b873a5f6c62b9efc0d5707b21622feebb0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 00:53:58 +0900 Subject: [PATCH 143/448] fix(project): recover interrupted publications --- CHANGELOG.md | 3 +- apps/desktop/src-tauri/src/main.rs | 2 + .../src-tauri/src/project_persistence.rs | 297 +++++++++++++++++- 3 files changed, 295 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c950d8a2..c14b4e9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - 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 @@ -81,4 +82,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0c78506be..c870d1e7e 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -752,6 +752,7 @@ fn save_project(payload: Value) -> Result<(), String> { let content = serde_json::to_string_pretty(&parsed) .map_err(|_| "Failed to serialize project".to_string())?; + project_persistence::recover_project_publication(&path)?; project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) @@ -764,6 +765,7 @@ fn load_project() -> Result { .pick_file() .ok_or_else(|| "User cancelled".to_string())?; + project_persistence::recover_project_publication(&path)?; let content = project_persistence::read_project_file(&path)?; project_payload_from_content(&content) } diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 3c4d40a67..b8d35278b 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -3,13 +3,16 @@ use std::{ 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 5MB limit)"; +const PROJECT_RECOVERY_ERROR: &str = "Could not recover the project publication safely."; #[cfg(windows)] const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; @@ -361,7 +364,7 @@ struct WindowsByHandleFileInformation { } #[cfg(windows)] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct WindowsFileIdentity { volume_serial_number: u32, file_index: u64, @@ -420,7 +423,7 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct ProjectFileIdentity { device: u64, inode: u64, @@ -469,6 +472,248 @@ pub(crate) fn project_file_identity(_target: &Path) -> Result; + +#[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, + stage_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; + return Ok(name.as_bytes().to_vec()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + return 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}; + return Some(parent.join(OsStr::from_bytes(name))); + } + #[cfg(windows)] + { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + return 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))] +fn recovery_journal_name(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let Some(uuid) = name + .strip_prefix(".bandscope-recovery-") + .and_then(|value| value.strip_suffix(".journal")) + else { + return false; + }; + uuid::Uuid::parse_str(uuid).is_ok() +} + +#[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, + stage: &Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, +) -> Result { + let journal_path = project_parent(target).join(format!( + ".bandscope-recovery-{}.journal", + uuid::Uuid::new_v4() + )); + let journal = PublicationJournal { + version: 1, + target_name: journal_path_name(target)?, + stage_name: journal_path_name(stage)?, + 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 recover_publication_state( + target: &Path, + journal_path: &Path, + journal: &PublicationJournal, + stage: &Path, +) -> Result<(), String> { + let target_identity = project_file_identity_if_present(target)?; + let stage_identity = project_file_identity_if_present(stage)?; + + if target_identity.as_ref() == Some(&journal.candidate) + && stage_identity.as_ref() == Some(&journal.expected) + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + if rename_exchange(stage, target).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + + #[cfg(windows)] + { + let rollback_stage = staging_path(target)?; + if replace_file_with_backup(target, stage, &rollback_stage).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + remove_stage(&rollback_stage); + } + remove_stage(stage); + remove_stage(journal_path); + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.expected) + && stage_identity.as_ref() == Some(&journal.candidate) + { + remove_stage(stage); + remove_stage(journal_path); + return Ok(()); + } + + if stage_identity.is_none() + && target_identity + .as_ref() + .is_some_and(|identity| identity == &journal.expected || identity == &journal.candidate) + { + remove_stage(journal_path); + return Ok(()); + } + + Err(PROJECT_RECOVERY_ERROR.to_string()) +} + +/// Repairs one durable, adjacent publication journal when its target is selected again. +/// +/// Security Notes: journal and stage names are constrained to generated same-directory names; +/// target, journal, and stage paths must stay regular non-link files; journal reads use the bounded +/// no-follow project reader; mismatched identities fail closed without deleting either file. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { + let parent = project_parent(target); + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let target_name = journal_path_name(target)?; + for entry in fs::read_dir(parent).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())? { + let entry = entry.map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let journal_path = entry.path(); + if !recovery_journal_name(&journal_path) { + continue; + } + 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(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if content.len() > MAX_RECOVERY_JOURNAL_BYTES { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let journal: PublicationJournal = + serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if journal.target_name != target_name { + continue; + } + if journal.version != 1 || !generated_stage_name(&journal.stage_name) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let stage = path_from_journal_name(parent, &journal.stage_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + recover_publication_state(target, &journal_path, &journal, &stage)?; + } + 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, @@ -476,14 +721,17 @@ pub(crate) fn replace_existing_project_file( expected: &ProjectFileIdentity, ) -> Result<(), String> { let candidate = project_file_identity(stage)?; + let journal = create_publication_journal(target, stage, expected, &candidate)?; 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) { remove_stage(stage); + remove_stage(&journal); return Ok(()); } @@ -491,6 +739,7 @@ pub(crate) fn replace_existing_project_file( project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && rename_exchange(stage, target).is_ok() { remove_stage(stage); + remove_stage(&journal); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -503,14 +752,17 @@ pub(crate) fn replace_existing_project_file( ) -> Result<(), String> { let candidate = project_file_identity(stage)?; let backup = staging_path(target)?; + let journal = create_publication_journal(target, &backup, expected, &candidate)?; 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) { remove_stage(&backup); + remove_stage(&journal); return Ok(()); } @@ -518,6 +770,7 @@ pub(crate) fn replace_existing_project_file( project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { remove_stage(stage); + remove_stage(&journal); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -654,7 +907,7 @@ where /// 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, migration, and recovery semantics remain later #962 work. +/// 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) } @@ -677,9 +930,9 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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. Filesystems without the required native primitive fail closed. These checks do not claim -/// descriptor-bound protection for a parent-chain swap, authority before the first post-dialog identity -/// snapshot, or crash recovery if a process is terminated during a mismatch rollback; those remain -/// project-format work under #962. +/// 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) @@ -1013,6 +1266,38 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[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, + &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"); + } + #[test] fn save_project_command_routes_through_safe_publisher() { let main_source = include_str!("main.rs"); From 80bbf5f2376756105390d59180ee678ecf412d17 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 00:58:42 +0900 Subject: [PATCH 144/448] fix(project): satisfy persistence lint --- apps/desktop/src-tauri/src/project_persistence.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index b8d35278b..a85e794d7 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -498,12 +498,12 @@ fn journal_path_name(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; - return Ok(name.as_bytes().to_vec()); + Ok(name.as_bytes().to_vec()) } #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; - return Ok(name.encode_wide().collect()); + Ok(name.encode_wide().collect()) } } @@ -512,13 +512,13 @@ fn path_from_journal_name(parent: &Path, name: &JournalPathName) -> Option Date: Sun, 30 Aug 2026 01:12:08 +0900 Subject: [PATCH 145/448] fix(project): make recovery cleanup durable --- .../src-tauri/src/project_persistence.rs | 365 ++++++++++++++---- 1 file changed, 284 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a85e794d7..7f65e0340 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -485,7 +485,8 @@ type JournalPathName = Vec; struct PublicationJournal { version: u8, target_name: JournalPathName, - stage_name: JournalPathName, + candidate_name: JournalPathName, + displaced_name: JournalPathName, expected: ProjectFileIdentity, candidate: ProjectFileIdentity, } @@ -540,17 +541,32 @@ fn generated_stage_name(name: &JournalPathName) -> bool { } #[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn recovery_journal_name(path: &Path) -> bool { - let Some(name) = path.file_name().and_then(|value| value.to_str()) else { - return false; - }; - let Some(uuid) = name - .strip_prefix(".bandscope-recovery-") - .and_then(|value| value.strip_suffix(".journal")) - else { - return false; - }; - uuid::Uuid::parse_str(uuid).is_ok() +fn journal_target_key(target: &Path) -> Result { + let name = journal_path_name(target)?; + let mut hash = 0xcbf29ce484222325u64; + #[cfg(unix)] + for byte in name { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + #[cfg(windows)] + for unit in name { + for byte in unit.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + Ok(hash) +} + +#[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-{:016x}.{}.journal", + journal_target_key(target)?, + phase + ))) } #[cfg(unix)] @@ -568,18 +584,17 @@ fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { #[cfg(any(target_os = "linux", target_os = "macos", windows))] fn create_publication_journal( target: &Path, - stage: &Path, + candidate_stage: &Path, + displaced: &Path, expected: &ProjectFileIdentity, candidate: &ProjectFileIdentity, ) -> Result { - let journal_path = project_parent(target).join(format!( - ".bandscope-recovery-{}.journal", - uuid::Uuid::new_v4() - )); + let journal_path = publication_journal_path(target, false)?; let journal = PublicationJournal { version: 1, target_name: journal_path_name(target)?, - stage_name: journal_path_name(stage)?, + candidate_name: journal_path_name(candidate_stage)?, + displaced_name: journal_path_name(displaced)?, expected: expected.clone(), candidate: candidate.clone(), }; @@ -615,51 +630,150 @@ fn project_file_identity_if_present( } } +#[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) { + if sync_parent_directory(project_parent(target)).is_err() + || remove_recovery_artifact(stage).is_err() + || sync_parent_directory(project_parent(target)).is_err() + || remove_recovery_artifact(journal).is_err() + { + return; + } + let _ = sync_parent_directory(project_parent(target)); +} + #[cfg(any(target_os = "linux", target_os = "macos", windows))] fn recover_publication_state( target: &Path, journal_path: &Path, journal: &PublicationJournal, - stage: &Path, + candidate_stage: &Path, + displaced: &Path, + published: bool, ) -> Result<(), String> { let target_identity = project_file_identity_if_present(target)?; - let stage_identity = project_file_identity_if_present(stage)?; + 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) - && stage_identity.as_ref() == Some(&journal.expected) + && displaced_identity.as_ref() == Some(&journal.expected) { #[cfg(any(target_os = "linux", target_os = "macos"))] - if rename_exchange(stage, target).is_err() { + 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)] { - let rollback_stage = staging_path(target)?; - if replace_file_with_backup(target, stage, &rollback_stage).is_err() { + if replace_file_with_backup(target, displaced, candidate_stage).is_err() { return Err(PROJECT_RECOVERY_ERROR.to_string()); } - remove_stage(&rollback_stage); } - remove_stage(stage); - remove_stage(journal_path); + 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) - && stage_identity.as_ref() == Some(&journal.candidate) + && candidate_identity.as_ref() == Some(&journal.candidate) + && (displaced_identity.is_none() || displaced == candidate_stage) { - remove_stage(stage); - remove_stage(journal_path); + 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(()); } - if stage_identity.is_none() + if candidate_identity.is_none() + && displaced_identity.is_none() && target_identity .as_ref() .is_some_and(|identity| identity == &journal.expected || identity == &journal.candidate) { - remove_stage(journal_path); + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; return Ok(()); } @@ -668,9 +782,10 @@ fn recover_publication_state( /// Repairs one durable, adjacent publication journal when its target is selected again. /// -/// Security Notes: journal and stage names are constrained to generated same-directory names; -/// target, journal, and stage paths must stay regular non-link files; journal reads use the bounded -/// no-follow project reader; mismatched identities fail closed without deleting either file. +/// 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; mismatched identities fail closed without +/// deleting either file. #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { let parent = project_parent(target); @@ -678,34 +793,57 @@ pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { return Err(PROJECT_RECOVERY_ERROR.to_string()); } let target_name = journal_path_name(target)?; - for entry in fs::read_dir(parent).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())? { - let entry = entry.map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - let journal_path = entry.path(); - if !recovery_journal_name(&journal_path) { - continue; - } - 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(&journal_path) - .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - if content.len() > MAX_RECOVERY_JOURNAL_BYTES { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } - let journal: PublicationJournal = - serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - if journal.target_name != target_name { - continue; - } - if journal.version != 1 || !generated_stage_name(&journal.stage_name) { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } - let stage = path_from_journal_name(parent, &journal.stage_name) - .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; - recover_publication_state(target, &journal_path, &journal, &stage)?; + 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(()); + }; + 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_name != 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(()) } @@ -721,7 +859,7 @@ pub(crate) fn replace_existing_project_file( expected: &ProjectFileIdentity, ) -> Result<(), String> { let candidate = project_file_identity(stage)?; - let journal = create_publication_journal(target, stage, expected, &candidate)?; + let journal = create_publication_journal(target, stage, stage, expected, &candidate)?; if rename_exchange(stage, target).is_err() { remove_stage(stage); remove_stage(&journal); @@ -730,16 +868,13 @@ pub(crate) fn replace_existing_project_file( let displaced = project_file_identity(stage); if displaced.as_ref().is_ok_and(|identity| identity == expected) { - remove_stage(stage); - remove_stage(&journal); - return Ok(()); + return finish_successful_publication(&journal, stage, target); } let target_is_candidate = project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && rename_exchange(stage, target).is_ok() { - remove_stage(stage); - remove_stage(&journal); + finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -752,7 +887,7 @@ pub(crate) fn replace_existing_project_file( ) -> Result<(), String> { let candidate = project_file_identity(stage)?; let backup = staging_path(target)?; - let journal = create_publication_journal(target, &backup, expected, &candidate)?; + let journal = create_publication_journal(target, stage, &backup, expected, &candidate)?; if replace_file_with_backup(target, stage, &backup).is_err() { remove_stage(stage); remove_stage(&journal); @@ -761,16 +896,13 @@ pub(crate) fn replace_existing_project_file( let displaced = project_file_identity(&backup); if displaced.as_ref().is_ok_and(|identity| identity == expected) { - remove_stage(&backup); - remove_stage(&journal); - return Ok(()); + return finish_successful_publication(&journal, &backup, target); } let target_is_candidate = project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { - remove_stage(stage); - remove_stage(&journal); + finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -828,7 +960,12 @@ fn project_parent_chain_is_safe(parent: &Path) -> bool { }) } -fn read_project_file_with_opener(target: &Path, open_file: F) -> Result +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, { @@ -887,13 +1024,13 @@ where #[cfg(not(any(unix, windows)))] return Err(PROJECT_READ_ERROR.to_string()); - let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); + 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_PROJECT_FILE_BYTES { - return Err(PROJECT_TOO_LARGE_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()) } @@ -909,7 +1046,12 @@ where /// 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) + 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. @@ -1029,7 +1171,7 @@ where mod tests { use super::{ publish_new_project_file, read_project_file, read_project_file_with_opener, - MAX_PROJECT_FILE_BYTES, + MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR, }; use std::{ fs, @@ -1243,7 +1385,7 @@ mod tests { 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"); @@ -1283,6 +1425,7 @@ mod tests { let journal = super::create_publication_journal( &target, &stage, + &stage, &expected, &candidate_identity, ) @@ -1298,6 +1441,66 @@ mod tests { fs::remove_dir_all(root).expect("test 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("test 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("test directory should be removable"); + } + #[test] fn save_project_command_routes_through_safe_publisher() { let main_source = include_str!("main.rs"); From 3006cd6ec452e6e99e498fe098174f5a4e044a76 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 01:19:44 +0900 Subject: [PATCH 146/448] fix(project): recover raced publication rollback --- .../src-tauri/src/project_persistence.rs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 7f65e0340..0e7ab0bc2 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -725,7 +725,9 @@ fn recover_publication_state( } if target_identity.as_ref() == Some(&journal.candidate) - && displaced_identity.as_ref() == Some(&journal.expected) + && 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() { @@ -784,8 +786,7 @@ fn recover_publication_state( /// /// 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; mismatched identities fail closed without -/// deleting either file. +/// 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); @@ -1441,6 +1442,47 @@ mod tests { 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("test directory should be removable"); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn cleans_a_durable_published_journal_after_target_exchange() { From 070f4cdbcb994a60057e3a2f8b911bc51dc5d9ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:40:59 -0700 Subject: [PATCH 147/448] test(project): reproduce linked-folder recovery rejection --- .../project_persistence_linked_ancestor.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_linked_ancestor.rs 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"); +} From 1883196956b975b2e724f268fd7e574c12d14699 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:43:43 -0700 Subject: [PATCH 148/448] fix(project): allow safe linked-folder reads without recovery state --- apps/desktop/src-tauri/src/project_persistence.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 0e7ab0bc2..faffee579 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -790,9 +790,6 @@ fn recover_publication_state( #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { let parent = project_parent(target); - if !project_parent_chain_is_safe(parent) { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } let target_name = journal_path_name(target)?; let prepared_path = publication_journal_path(target, false)?; let published_path = publication_journal_path(target, true)?; @@ -810,6 +807,9 @@ pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { }) 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) { From 6fbedb8b9418787c44efb3fe8c401372b7b35da5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 11:07:19 -0700 Subject: [PATCH 149/448] test(project): reproduce stale recovery and stage leaks --- .../project_persistence_recovery_cleanup.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs 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..0d694bdc4 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs @@ -0,0 +1,109 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +mod unix_recovery_cleanup { + use super::project_persistence; + use std::{ + ffi::OsStr, + 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 target_key(name: &OsStr) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{:016x}.prepared.journal", + target_key(target.file_name().expect("fixture target should have a name")) + )) + } + + #[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"); + } +} From 817cb56e4f8caf46fe76423b4160ad790d0957ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 11:09:45 -0700 Subject: [PATCH 150/448] fix(project): retire stale recovery state safely --- .../src-tauri/src/project_persistence.rs | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index faffee579..b139a73b4 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -767,12 +767,7 @@ fn recover_publication_state( return Ok(()); } - if candidate_identity.is_none() - && displaced_identity.is_none() - && target_identity - .as_ref() - .is_some_and(|identity| identity == &journal.expected || identity == &journal.candidate) - { + 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())?; @@ -859,8 +854,20 @@ pub(crate) fn replace_existing_project_file( target: &Path, expected: &ProjectFileIdentity, ) -> Result<(), String> { - let candidate = project_file_identity(stage)?; - let journal = create_publication_journal(target, stage, stage, expected, &candidate)?; + 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); @@ -886,9 +893,21 @@ pub(crate) fn replace_existing_project_file( target: &Path, expected: &ProjectFileIdentity, ) -> Result<(), String> { - let candidate = project_file_identity(stage)?; + 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 = create_publication_journal(target, stage, &backup, expected, &candidate)?; + 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); @@ -1409,7 +1428,7 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } - #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn recovers_an_interrupted_existing_project_publication() { let root = test_dir("recovery"); From 86b973f8ed46bfabb9f4ab795d1f2a03c7db6ddb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 13:10:39 -0700 Subject: [PATCH 151/448] test(project): strengthen atomic publication guard --- .../src-tauri/tests/project_persistence_atomic_publication.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs index d61f8bc09..c74423707 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs @@ -3,7 +3,8 @@ 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)") + && !source.contains("File::create_new(&target)"), "hard-link fallback must not materialize an empty final-path placeholder before the staged project is atomically published" ); } From b2edbe9521cb5803f1a9b7e9fbd49a65e3841aad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:12:46 -0700 Subject: [PATCH 152/448] test(project): cover Windows recovery cleanup --- .../project_persistence_recovery_cleanup.rs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs index 0d694bdc4..58787a57c 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs @@ -107,3 +107,117 @@ mod unix_recovery_cleanup { fs::remove_dir_all(root).expect("test directory should be removable"); } } + +#[cfg(windows)] +mod windows_recovery_cleanup { + use super::project_persistence; + use std::{ + ffi::OsStr, + 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 target_key(name: &OsStr) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for unit in name.encode_wide() { + for byte in unit.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash + } + + fn prepared_journal_path(target: &Path) -> PathBuf { + target.parent().expect("fixture target should have a parent").join(format!( + ".bandscope-recovery-{:016x}.prepared.journal", + target_key(target.file_name().expect("fixture target should have a name")) + )) + } + + 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"); + } +} From 00c7792984aa8321d468d8148b293f5d4b11c830 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:13:15 -0700 Subject: [PATCH 153/448] ci(project): run recovery cleanup on Windows --- .../workflows/project-persistence-windows.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/project-persistence-windows.yml diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml new file mode 100644 index 000000000..3fff54633 --- /dev/null +++ b/.github/workflows/project-persistence-windows.yml @@ -0,0 +1,37 @@ +name: project-persistence-windows + +on: + pull_request: + branches: + - develop + - main + paths: + - "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/src-tauri/src/project_persistence.rs" + - "apps/desktop/src-tauri/tests/project_persistence*.rs" + - ".github/workflows/project-persistence-windows.yml" + +permissions: + contents: read + +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 stable + run: rustup toolchain install stable --profile minimal + - name: Run Windows recovery-cleanup regression + run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --test project_persistence_recovery_cleanup From 1da4a1d9f8e8f89088fc5eb925157e146c9e1987 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 14:18:07 -0700 Subject: [PATCH 154/448] ci(project): satisfy Tauri context for Windows test --- .github/workflows/project-persistence-windows.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index 3fff54633..eb4fd3302 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -33,5 +33,10 @@ jobs: persist-credentials: false - name: Install Rust stable run: rustup toolchain install stable --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 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --test project_persistence_recovery_cleanup From dbee8b9ce385594748788fdb043cad1f8a24fe01 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 06:41:27 +0900 Subject: [PATCH 155/448] ci(project): set checkout default branch environment --- .github/workflows/project-persistence-windows.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index eb4fd3302..970da8406 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -21,6 +21,11 @@ on: 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 From d9adf114ac4c07b94acf4bc6338eacc6b69e6fd6 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 06:48:43 +0900 Subject: [PATCH 156/448] fix(project): harden recovery identity and file modes --- .../src-tauri/src/project_persistence.rs | 117 +++++++++++++++--- .../tests/project_persistence_permissions.rs | 35 ++++++ .../project_persistence_recovery_cleanup.rs | 32 +---- 3 files changed, 142 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index b139a73b4..5bc070990 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -541,29 +541,44 @@ fn generated_stage_name(name: &JournalPathName) -> bool { } #[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn journal_target_key(target: &Path) -> Result { - let name = journal_path_name(target)?; - let mut hash = 0xcbf29ce484222325u64; +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)] - for byte in name { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); + { + use std::os::unix::ffi::OsStrExt; + + for byte in canonical_target.as_os_str().as_bytes() { + update(*byte); + } } #[cfg(windows)] - for unit in name { - for byte in unit.to_le_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); + { + 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(hash) + 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-{:016x}.{}.journal", + ".bandscope-recovery-{}.{}.journal", journal_target_key(target)?, phase ))) @@ -777,6 +792,31 @@ fn recover_publication_state( 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 @@ -785,7 +825,6 @@ fn recover_publication_state( #[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 target_name = journal_path_name(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)?; @@ -819,7 +858,7 @@ pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { .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_name != target_name { + if !journal_target_matches(target, parent, &journal.target_name) { return Err(PROJECT_RECOVERY_ERROR.to_string()); } if journal.version != 1 @@ -1083,7 +1122,8 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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 those permissions after its bytes are written and before it is synced. +/// 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 @@ -1145,7 +1185,10 @@ where } #[cfg(unix)] if let Some((_, permissions)) = expected_target.as_ref() { - if staged.set_permissions(permissions.clone()).is_err() { + 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()); @@ -1294,6 +1337,48 @@ mod tests { 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"); + } + #[test] fn invalid_replacement_does_not_clobber_an_existing_known_good_project() { let root = test_dir("existing-invalid"); diff --git a/apps/desktop/src-tauri/tests/project_persistence_permissions.rs b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs index de867530c..0bd2105f2 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_permissions.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_permissions.rs @@ -38,3 +38,38 @@ fn existing_project_overwrite_preserves_restrictive_mode() { ); 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 index 58787a57c..9191f6a3b 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_recovery_cleanup.rs @@ -5,7 +5,6 @@ mod project_persistence; mod unix_recovery_cleanup { use super::project_persistence; use std::{ - ffi::OsStr, fs, os::unix::ffi::OsStrExt, path::{Path, PathBuf}, @@ -25,19 +24,11 @@ mod unix_recovery_cleanup { path } - fn target_key(name: &OsStr) -> u64 { - let mut hash = 0xcbf29ce484222325u64; - for byte in name.as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x100000001b3); - } - hash - } - fn prepared_journal_path(target: &Path) -> PathBuf { target.parent().expect("fixture target should have a parent").join(format!( - ".bandscope-recovery-{:016x}.prepared.journal", - target_key(target.file_name().expect("fixture target should have a name")) + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") )) } @@ -112,7 +103,6 @@ mod unix_recovery_cleanup { mod windows_recovery_cleanup { use super::project_persistence; use std::{ - ffi::OsStr, fs, os::windows::ffi::OsStrExt, path::{Path, PathBuf}, @@ -132,21 +122,11 @@ mod windows_recovery_cleanup { path } - fn target_key(name: &OsStr) -> u64 { - let mut hash = 0xcbf29ce484222325u64; - for unit in name.encode_wide() { - for byte in unit.to_le_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); - } - } - hash - } - fn prepared_journal_path(target: &Path) -> PathBuf { target.parent().expect("fixture target should have a parent").join(format!( - ".bandscope-recovery-{:016x}.prepared.journal", - target_key(target.file_name().expect("fixture target should have a name")) + ".bandscope-recovery-{}.prepared.journal", + project_persistence::journal_target_key(target) + .expect("fixture target key should be derivable") )) } From 9b7e3e90c03d775b2cc1181d6aaac2a66c8f1223 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 06:59:36 +0900 Subject: [PATCH 157/448] fix(project): recover after interrupted rollback --- .../workflows/project-persistence-windows.yml | 2 +- .../src-tauri/src/project_persistence.rs | 78 ++++++++++++++++--- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index 970da8406..0cb85f410 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -44,4 +44,4 @@ jobs: 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 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --test project_persistence_recovery_cleanup + run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 5bc070990..e454263e4 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -692,15 +692,20 @@ fn finish_successful_publication( } #[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn finish_rolled_back_publication(stage: &Path, journal: &Path, target: &Path) { - if sync_parent_directory(project_parent(target)).is_err() - || remove_recovery_artifact(stage).is_err() - || sync_parent_directory(project_parent(target)).is_err() - || remove_recovery_artifact(journal).is_err() - { - return; - } - let _ = sync_parent_directory(project_parent(target)); +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))] @@ -782,6 +787,16 @@ fn recover_publication_state( 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)) @@ -921,7 +936,7 @@ pub(crate) fn replace_existing_project_file( let target_is_candidate = project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && rename_exchange(stage, target).is_ok() { - finish_rolled_back_publication(stage, &journal, target); + let _ = finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -961,7 +976,7 @@ pub(crate) fn replace_existing_project_file( 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() { - finish_rolled_back_publication(stage, &journal, target); + let _ = finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -1379,6 +1394,47 @@ mod tests { 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"); From 64820be4f840a211ad5f29e7619e61ea83baf72a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 07:39:30 +0900 Subject: [PATCH 158/448] feat(project): add versioned project file envelope --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + apps/desktop/core/src/lib.rs | 98 +++++++++++++++++++++- apps/desktop/core/testdata/project-v1.json | 67 +++++++++++++++ apps/desktop/src-tauri/src/main.rs | 3 +- docs/engineering/local-project-format.md | 30 ++++--- 6 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/core/testdata/project-v1.json 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 c14b4e9f3..3044738ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +- 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 diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..b3a102b1a 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,27 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } +/// 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. @@ -528,12 +543,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 payload.get("projectFormatVersion").is_some() { + let envelope = serde_json::from_value::(payload) + .map_err(|_| "Invalid project file format".to_string())?; + if envelope.project_format_version != CURRENT_PROJECT_FORMAT_VERSION { + return Err(format!( + "Unsupported project format version: {}", + envelope.project_format_version + )); + } + return Ok(envelope.song); + } + + if let Ok(parsed) = serde_json::from_value::(payload.clone()) { return Ok(parsed); } - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; if let Some(sections) = payload.get("sections").and_then(Value::as_array) { for (section_index, section) in sections.iter().enumerate() { if section @@ -550,6 +578,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 +906,61 @@ 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" + ); + } + #[test] fn project_payload_from_content_rejects_malformed_or_incomplete_payloads() { assert_eq!( 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/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c870d1e7e..6fe3a00ff 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -750,8 +750,7 @@ 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())?; + let content = project_content_for_payload(&parsed)?; project_persistence::recover_project_publication(&path)?; project_persistence::publish_new_project_file(&path, content.as_bytes())?; diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 4c4368f2c..7d34e3b30 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -4,27 +4,33 @@ This document specifies the format and lifecycle of a BandScope `.bscope` projec ## 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 standard JSON envelope with `projectFormatVersion: 1`; the nested `song` is the current compatibility view used by the desktop contract. Older raw `RehearsalSong` JSON remains loadable as an explicit legacy input and is never silently rewritten in memory as a newer version. ## Schema The primary data structure for a `.bscope` file is the `RehearsalSong` type from `@bandscope/shared-types`. -### Top-Level Structure +### Top-Level Structure (version 1) ```json { - "id": "string", - "title": "string", - "sections": [ ... ], - "exportSummary": { - "format": "cue-sheet", - "headline": "string", - "focusSections": ["string"] + "projectFormatVersion": 1, + "song": { + "id": "string", + "title": "string", + "tempo": 120, + "sections": [ ... ], + "exportSummary": { + "format": "cue-sheet", + "headline": "string", + "focusSections": ["string"] + } } } ``` +The version is independent of the application package version. The v1 reader rejects unknown envelope fields and returns an explicit unsupported-version error for a well-formed future version. The checked-in golden fixture is `apps/desktop/core/testdata/project-v1.json`. + ### 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). @@ -80,6 +86,10 @@ When loading `.bscope` files from disk, BandScope applies the following constrai 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. +## Current boundary and next migration slices + +Version 1 deliberately keeps the existing validated `RehearsalSong` as the compatibility view. Source references, derived analysis artifacts, user decisions, portable handoff data, UI preferences, and volatile player state are not fabricated or written into untyped bags. Their typed promotion, bounded autosave journal, backup rotation, migration receipts, and accessible restore/compare/discard flow remain the next #962 slices. Player state must use this authority after the transport state machine is stable; it must not create a second localStorage or session persistence authority. + ## 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. +Future updates to the `.bscope` format must add an ordered migration from the prior envelope, validate a copy before publication, retain the prior known-good artifact, and update the machine-verifiable fixture. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded. From d3337cf7bbe20884a3c5330291b1b9f7dbe044f0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 07:51:07 +0900 Subject: [PATCH 159/448] fix(project): validate version and tempo before decode --- apps/desktop/core/src/lib.rs | 72 ++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index b3a102b1a..81cc9cdee 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,7 +122,11 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_project_tempo", + skip_serializing_if = "Option::is_none" + )] tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, @@ -130,6 +134,24 @@ pub struct RehearsalSongPayload { 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", + )), + } +} + /// Current on-disk project format version, independent of the app version. pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 1; @@ -546,15 +568,15 @@ pub fn project_payload_from_content(content: &str) -> Result(content) .map_err(|_| "Invalid project file format".to_string())?; - if payload.get("projectFormatVersion").is_some() { + 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())?; - if envelope.project_format_version != CURRENT_PROJECT_FORMAT_VERSION { - return Err(format!( - "Unsupported project format version: {}", - envelope.project_format_version - )); - } return Ok(envelope.song); } @@ -959,6 +981,40 @@ mod tests { .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] From ecc2279c7fe42fc021e39180bd27f5f325a2eb74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:17:51 +0900 Subject: [PATCH 160/448] test(audio): require actionable oversize rejection copy --- .../core/tests/audio_resource_next_action.rs | 9 +++ ...nalysis.audio-resource-next-action.test.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/desktop/core/tests/audio_resource_next_action.rs create mode 100644 apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts diff --git a/apps/desktop/core/tests/audio_resource_next_action.rs b/apps/desktop/core/tests/audio_resource_next_action.rs new file mode 100644 index 000000000..9fa77d800 --- /dev/null +++ b/apps/desktop/core/tests/audio_resource_next_action.rs @@ -0,0 +1,9 @@ +use bandscope_desktop_core::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; + +#[test] +fn oversized_local_audio_names_the_next_rehearsal_action() { + assert_eq!( + validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES + 1), + Err("Choose a shorter or smaller song file to start analysis.".to_string()) + ); +} diff --git a/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts b/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts new file mode 100644 index 000000000..8643ab84b --- /dev/null +++ b/apps/desktop/src/lib/analysis.audio-resource-next-action.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + MAX_LOCAL_AUDIO_FILE_BYTES, + importYoutubeUrl, + selectLocalAudioSource +} from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const NEXT_ACTION = "Choose a shorter or smaller song file to start analysis."; + +function oversizedBootstrap(projectId: string) { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/cache/${projectId}`, + tempRoot: `/tmp/bandscope/temp/${projectId}`, + source: { + sourcePath: `/tmp/bandscope/${projectId}/input.wav`, + fileName: "input.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }; +} + +describe("audio resource rejection next action", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("names the next action for an oversized local selection", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(oversizedBootstrap("local-project")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { code: "invalid_request", message: NEXT_ACTION } + }); + }); + + it("names the same next action for an oversized imported selection", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue(oversizedBootstrap("youtube-project")); + + await expect(importYoutubeUrl("https://youtu.be/4ozX4yFUC34")).resolves.toEqual({ + ok: false, + error: { code: "invalid_request", message: NEXT_ACTION } + }); + }); +}); From f88d3a48d78a64e98594ed7c673028579492f147 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:19:02 +0900 Subject: [PATCH 161/448] fix(audio): name the next action for oversized sources --- apps/desktop/core/src/audio_resource.rs | 2 +- apps/desktop/src/lib/analysis.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index 88c07e425..8c47456aa 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -3,7 +3,7 @@ pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; const LOCAL_AUDIO_READ_ERROR: &str = "Could not read the selected audio file."; const LOCAL_AUDIO_TOO_LARGE_ERROR: &str = - "Selected audio file exceeds the 100 MiB analysis limit."; + "Choose a shorter or smaller song file to start analysis."; /// Validate a native local-audio file length before storing bootstrap metadata. /// diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 6ff443320..33adb504b 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -35,7 +35,7 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; -const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Selected audio file exceeds the 100 MiB analysis limit."; +const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Choose a shorter or smaller song file to start analysis."; const LOCAL_AUDIO_POLICY_MESSAGE = "Selected audio file metadata violates the analysis resource policy."; const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; From 3ac09a669a137a239a72a6ccfe58a3acddb900b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:34:02 +0900 Subject: [PATCH 162/448] test(audio): preserve structured resource rejection contract --- .../tests/test_audio_resource_policy.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_resource_policy.py b/services/analysis-engine/tests/test_audio_resource_policy.py index 2d52f44a5..0639590cc 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy.py +++ b/services/analysis-engine/tests/test_audio_resource_policy.py @@ -13,6 +13,7 @@ DEFAULT_MIN_SOURCE_CHANNELS, DEFAULT_MIN_SOURCE_SAMPLE_RATE, AudioResourcePolicy, + AudioResourcePolicyError, ) @@ -26,6 +27,62 @@ def test_default_policy_has_stable_version_and_rehearsal_budget() -> None: assert DEFAULT_AUDIO_RESOURCE_POLICY.max_decoded_audio_bytes == 44_100 * 15 * 60 * 8 +def test_oversized_encoded_file_exposes_stable_policy_reason() -> None: + """Encoded-size rejection carries a stable reason and policy version for UI/provenance.""" + policy = AudioResourcePolicy(max_encoded_file_bytes=100) + + with pytest.raises(AudioResourcePolicyError) as captured: + policy.validate_encoded_file_bytes(101) + + assert captured.value.reason == "encoded_file_too_large" + assert captured.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + assert "audio resource policy" in str(captured.value).lower() + + +def test_source_metadata_exposes_stable_policy_reasons() -> None: + """Container admission distinguishes duration, rate, and channel rejection reasons.""" + policy = AudioResourcePolicy(max_duration_seconds=1.0) + + with pytest.raises(AudioResourcePolicyError) as duration_rejection: + policy.validate_source_metadata(frames=44_101, sample_rate=44_100, channels=2) + assert duration_rejection.value.reason == "duration_exceeded" + assert duration_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + with pytest.raises(AudioResourcePolicyError) as rate_rejection: + policy.validate_source_metadata( + frames=44_100, + sample_rate=DEFAULT_MAX_SOURCE_SAMPLE_RATE + 1, + channels=2, + ) + assert rate_rejection.value.reason == "sampling_rate_unsupported" + assert rate_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + with pytest.raises(AudioResourcePolicyError) as channel_rejection: + policy.validate_source_metadata( + frames=44_100, + sample_rate=44_100, + channels=DEFAULT_MAX_SOURCE_CHANNELS + 1, + ) + assert channel_rejection.value.reason == "channel_count_unsupported" + assert channel_rejection.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + +def test_decoded_memory_rejection_exposes_stable_policy_reason() -> None: + """Post-decode memory rejection remains machine-readable without exposing payload data.""" + policy = AudioResourcePolicy( + target_sample_rate=8, + max_duration_seconds=1.0, + max_decoded_audio_bytes=16, + ) + audio = np.zeros(4, dtype=np.float64) + + with pytest.raises(AudioResourcePolicyError) as captured: + policy.validate_decoded_audio(audio, 8) + + assert captured.value.reason == "memory_budget_exceeded" + assert captured.value.policy_version == AUDIO_RESOURCE_POLICY_VERSION + + @pytest.mark.parametrize("file_size", [True, -1, 0, 101]) def test_encoded_file_size_fails_closed_outside_policy(file_size: object) -> None: """Invalid, empty, or oversized encoded inputs are rejected before decode.""" From 336195a3d799fc33891bdd3739882944bb859375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:36:43 +0900 Subject: [PATCH 163/448] fix(audio): preserve resource rejection provenance --- .../audio_resource_policy.py | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py index 63e7194b6..5badde63b 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_resource_policy.py @@ -16,8 +16,8 @@ rejected instead of being silently truncated to the accepted duration. - Policy arithmetic rejects unrepresentable limits before float/sample-count conversion so malformed configuration cannot escape the stable failure mode. -- Validation errors are payload-free and never include source paths or audio - content. +- Resource rejections expose only a stable reason and policy version; messages + remain payload-free and never include source paths or audio content. """ from __future__ import annotations @@ -25,7 +25,7 @@ import math import sys from dataclasses import dataclass -from typing import Any, cast +from typing import Any, NoReturn, cast import numpy as np from numpy.typing import NDArray @@ -44,6 +44,21 @@ _POLICY_ERROR = "Audio input violates the audio resource policy." +class AudioResourcePolicyError(ValueError): + """Payload-free resource rejection with stable machine-readable provenance.""" + + def __init__(self, reason: str) -> None: + """Record a stable rejection reason and the policy version that produced it.""" + super().__init__(_POLICY_ERROR) + self.reason = reason + self.policy_version = AUDIO_RESOURCE_POLICY_VERSION + + +def _reject(reason: str) -> NoReturn: + """Fail closed without echoing untrusted resource metadata.""" + raise AudioResourcePolicyError(reason) + + @dataclass(frozen=True) class AudioResourcePolicy: """Versioned limits applied before and after local audio decoding. @@ -154,15 +169,13 @@ def validate_encoded_file_bytes(self, file_size: object) -> int: The validated integer byte count. Raises: - ValueError: If the value is not a positive integer within policy. + AudioResourcePolicyError: If the value is not a positive integer + within policy. """ - if ( - isinstance(file_size, bool) - or not isinstance(file_size, int) - or file_size <= 0 - or file_size > self.max_encoded_file_bytes - ): - raise ValueError(_POLICY_ERROR) + if isinstance(file_size, bool) or not isinstance(file_size, int) or file_size <= 0: + _reject("malformed_header") + if file_size > self.max_encoded_file_bytes: + _reject("encoded_file_too_large") return file_size def validate_source_metadata( @@ -179,28 +192,31 @@ def validate_source_metadata( channels: Source channel count before downmixing. Raises: - ValueError: If metadata is malformed or outside the source bounds. + AudioResourcePolicyError: If metadata is malformed or outside the + source bounds. """ + if isinstance(frames, bool) or not isinstance(frames, int) or frames <= 0: + _reject("malformed_header") if ( - isinstance(frames, bool) - or not isinstance(frames, int) - or frames <= 0 - or isinstance(sample_rate, bool) + isinstance(sample_rate, bool) or not isinstance(sample_rate, int) or sample_rate < self.min_source_sample_rate or sample_rate > self.max_source_sample_rate - or isinstance(channels, bool) + ): + _reject("sampling_rate_unsupported") + if ( + isinstance(channels, bool) or not isinstance(channels, int) or channels < self.min_source_channels or channels > self.max_source_channels ): - raise ValueError(_POLICY_ERROR) + _reject("channel_count_unsupported") try: source_duration_seconds = float(frames) / float(sample_rate) except (OverflowError, ValueError): - raise ValueError(_POLICY_ERROR) from None + _reject("malformed_header") if source_duration_seconds > float(self.max_duration_seconds): - raise ValueError(_POLICY_ERROR) + _reject("duration_exceeded") def validate_decoded_audio( self, @@ -217,8 +233,8 @@ def validate_decoded_audio( The original validated NumPy floating-point array without copying it. Raises: - ValueError: If dtype, shape, sample rate, sample count, memory use, - or finiteness does not satisfy this policy. + AudioResourcePolicyError: If dtype, shape, sample rate, sample + count, memory use, or finiteness does not satisfy this policy. """ if ( not isinstance(audio, np.ndarray) @@ -226,19 +242,19 @@ def validate_decoded_audio( or audio.size == 0 or not np.issubdtype(audio.dtype, np.floating) ): - raise ValueError(_POLICY_ERROR) + _reject("malformed_header") if ( isinstance(sample_rate, bool) or not isinstance(sample_rate, int) or sample_rate != self.target_sample_rate ): - raise ValueError(_POLICY_ERROR) - if ( - audio.size > self.max_decoded_samples - or audio.nbytes > self.max_decoded_audio_bytes - or not np.isfinite(audio).all() - ): - raise ValueError(_POLICY_ERROR) + _reject("sampling_rate_unsupported") + if audio.size > self.max_decoded_samples: + _reject("decoded_sample_count_exceeded") + if audio.nbytes > self.max_decoded_audio_bytes: + _reject("memory_budget_exceeded") + if not np.isfinite(audio).all(): + _reject("malformed_header") return cast(NDArray[np.floating[Any]], audio) @@ -247,6 +263,7 @@ def validate_decoded_audio( __all__ = [ "AUDIO_RESOURCE_POLICY_VERSION", "AudioResourcePolicy", + "AudioResourcePolicyError", "DEFAULT_AUDIO_RESOURCE_POLICY", "DEFAULT_MAX_DECODED_AUDIO_BYTES", "DEFAULT_MAX_DURATION_SECONDS", From 7862ad27fc525f708505ac84aaf239c63eb4d505 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:42:44 +0900 Subject: [PATCH 164/448] test(audio): fail closed on parser ValueError leakage --- .../tests/test_audio_metadata.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_metadata.py b/services/analysis-engine/tests/test_audio_metadata.py index 4c9b5c0ca..084913864 100644 --- a/services/analysis-engine/tests/test_audio_metadata.py +++ b/services/analysis-engine/tests/test_audio_metadata.py @@ -9,6 +9,7 @@ import pytest from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import AudioResourcePolicyError def _info(*, frames: int = 44_100, samplerate: int = 44_100, channels: int = 2) -> SimpleNamespace: @@ -36,9 +37,9 @@ def inspect(handle: io.BytesIO) -> SimpleNamespace: @pytest.mark.parametrize( ("info", "reason"), [ - (_info(frames=44_100 * 901), "audio resource policy"), - (_info(samplerate=7_999), "audio resource policy"), - (_info(channels=3), "audio resource policy"), + (_info(frames=44_100 * 901), "duration_exceeded"), + (_info(samplerate=7_999), "sampling_rate_unsupported"), + (_info(channels=3), "channel_count_unsupported"), ], ) @patch("bandscope_analysis.audio_metadata.soundfile.info") @@ -50,19 +51,26 @@ def test_preflight_rejects_untrusted_source_metadata( """Source duration, rate, and channel bounds fail before PCM decode.""" mock_info.return_value = info # type: ignore[attr-defined] - with pytest.raises(ValueError, match=reason): + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: preflight_audio_metadata(io.BytesIO(b"header")) + assert error.value.reason == reason -@patch( - "bandscope_analysis.audio_metadata.soundfile.info", - side_effect=RuntimeError("decoder detail"), -) -def test_preflight_maps_parser_failures_to_payload_free_policy_error(_mock_info: object) -> None: - """Container parser failures do not leak decoder details.""" - with pytest.raises(ValueError, match="audio resource policy") as error: - preflight_audio_metadata(io.BytesIO(b"bad-header")) +@pytest.mark.parametrize("dependency_error", [RuntimeError("decoder detail"), ValueError("decoder detail")]) +def test_preflight_maps_parser_failures_to_payload_free_policy_error( + dependency_error: Exception, +) -> None: + """Container parser failures cannot masquerade as policy errors or leak decoder detail.""" + with patch( + "bandscope_analysis.audio_metadata.soundfile.info", + side_effect=dependency_error, + ): + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: + preflight_audio_metadata(io.BytesIO(b"bad-header")) + + assert error.value.reason == "malformed_header" + assert error.value.policy_version == "1" assert "decoder detail" not in str(error.value) @@ -87,7 +95,9 @@ def seek(self, *args: object, **kwargs: object) -> int: mock_info.return_value = _info() # type: ignore[attr-defined] - with pytest.raises(ValueError, match="audio resource policy") as error: + with pytest.raises(AudioResourcePolicyError, match="audio resource policy") as error: preflight_audio_metadata(SeekFailsAfterProbe()) + assert error.value.reason == "malformed_header" + assert error.value.policy_version == "1" assert "rewind failed" not in str(error.value) From 1f33cde5d58af0aeca47fba42c1dab9db74bff2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:43:01 +0900 Subject: [PATCH 165/448] fix(audio): contain metadata parser ValueError details --- .../src/bandscope_analysis/audio_metadata.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py index 9f5874d8e..51e0a6176 100644 --- a/services/analysis-engine/src/bandscope_analysis/audio_metadata.py +++ b/services/analysis-engine/src/bandscope_analysis/audio_metadata.py @@ -19,10 +19,9 @@ from bandscope_analysis.audio_resource_policy import ( DEFAULT_AUDIO_RESOURCE_POLICY, AudioResourcePolicy, + AudioResourcePolicyError, ) -_POLICY_ERROR = "Audio input violates the audio resource policy." - def preflight_audio_metadata( fileobj: BinaryIO, @@ -38,7 +37,7 @@ def preflight_audio_metadata( sample_rate=info.samplerate, channels=info.channels, ) - except ValueError: + except AudioResourcePolicyError: raise except Exception as error: - raise ValueError(_POLICY_ERROR) from error + raise AudioResourcePolicyError("malformed_header") from error From 2ca91e05b229b38d132ccdbc7f1f7c2a29675aa9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:17:29 +0900 Subject: [PATCH 166/448] test(audio): require one canonical PCM decode port --- .../tests/test_audio_decode_port.py | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_decode_port.py diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py new file mode 100644 index 000000000..2c9fb2ee1 --- /dev/null +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import io + +import numpy as np +import pytest + +from bandscope_analysis import audio_decode +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicyError, +) + + +def test_decode_mono_audio_preflights_then_validates_one_owned_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = io.BytesIO(b"container") + calls: list[tuple[str, object]] = [] + decoder_output = np.array([[0.25, -0.5]], dtype=np.float64) + + def preflight(candidate: object, policy: object) -> None: + calls.append(("preflight", candidate)) + assert policy is DEFAULT_AUDIO_RESOURCE_POLICY + + def load(candidate: object, **kwargs: object) -> tuple[np.ndarray, int]: + calls.append(("decode", candidate)) + assert candidate is source + assert kwargs == { + "sr": DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate, + "mono": True, + "duration": DEFAULT_AUDIO_RESOURCE_POLICY.decode_probe_duration_seconds, + } + return decoder_output, DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + + def validate(decoded: object, sample_rate: object) -> np.ndarray: + calls.append(("validate", decoded)) + assert isinstance(decoded, np.ndarray) + assert decoded.dtype == np.float32 + assert decoded.shape == (2,) + assert sample_rate == DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + return decoded + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", preflight) + monkeypatch.setattr(audio_decode.librosa, "load", load) + monkeypatch.setattr(DEFAULT_AUDIO_RESOURCE_POLICY, "validate_decoded_audio", validate) + + decoded, sample_rate = audio_decode.decode_mono_audio( + source, + policy=DEFAULT_AUDIO_RESOURCE_POLICY, + ) + + assert calls[0] == ("preflight", source) + assert calls[1] == ("decode", source) + assert calls[2][0] == "validate" + np.testing.assert_array_equal(decoded, np.array([0.25, -0.5], dtype=np.float32)) + assert sample_rate == DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate + + +def test_decode_mono_audio_preserves_resource_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rejection = AudioResourcePolicyError("duration_exceeded") + + def reject(_source: object, _policy: object) -> None: + raise rejection + + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", reject) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: pytest.fail("decoder must not run after rejected preflight"), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value is rejection + + +def test_decode_mono_audio_redacts_third_party_decoder_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret_detail = "/Users/alice/Music/private.m4a token=secret" + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError(secret_detail)), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value.reason == "malformed_header" + assert secret_detail not in str(caught.value) + assert isinstance(caught.value.__cause__, RuntimeError) + + +def test_decode_mono_audio_redacts_malformed_decoder_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: ([object()], DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value.reason == "malformed_header" + + +def test_decode_mono_audio_preserves_decoded_policy_rejection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rejection = AudioResourcePolicyError("decoded_sample_count_exceeded") + monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) + monkeypatch.setattr( + audio_decode.librosa, + "load", + lambda *_args, **_kwargs: ( + np.array([0.1], dtype=np.float32), + DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate, + ), + ) + monkeypatch.setattr( + DEFAULT_AUDIO_RESOURCE_POLICY, + "validate_decoded_audio", + lambda *_args: (_ for _ in ()).throw(rejection), + ) + + with pytest.raises(AudioResourcePolicyError) as caught: + audio_decode.decode_mono_audio(io.BytesIO(b"container")) + + assert caught.value is rejection From 5cc67e1140f7aa21614feb226920045f95091373 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:18:11 +0900 Subject: [PATCH 167/448] test(audio): keep decode-port RED compatible with frozen policy --- .../tests/test_audio_decode_port.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py index 2c9fb2ee1..cbeb25852 100644 --- a/services/analysis-engine/tests/test_audio_decode_port.py +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -8,6 +8,7 @@ from bandscope_analysis import audio_decode from bandscope_analysis.audio_resource_policy import ( DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, AudioResourcePolicyError, ) @@ -33,8 +34,9 @@ def load(candidate: object, **kwargs: object) -> tuple[np.ndarray, int]: } return decoder_output, DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate - def validate(decoded: object, sample_rate: object) -> np.ndarray: + def validate(self: AudioResourcePolicy, decoded: object, sample_rate: object) -> np.ndarray: calls.append(("validate", decoded)) + assert self is DEFAULT_AUDIO_RESOURCE_POLICY assert isinstance(decoded, np.ndarray) assert decoded.dtype == np.float32 assert decoded.shape == (2,) @@ -43,7 +45,7 @@ def validate(decoded: object, sample_rate: object) -> np.ndarray: monkeypatch.setattr(audio_decode, "preflight_audio_metadata", preflight) monkeypatch.setattr(audio_decode.librosa, "load", load) - monkeypatch.setattr(DEFAULT_AUDIO_RESOURCE_POLICY, "validate_decoded_audio", validate) + monkeypatch.setattr(AudioResourcePolicy, "validate_decoded_audio", validate) decoded, sample_rate = audio_decode.decode_mono_audio( source, @@ -126,11 +128,13 @@ def test_decode_mono_audio_preserves_decoded_policy_rejection( DEFAULT_AUDIO_RESOURCE_POLICY.target_sample_rate, ), ) - monkeypatch.setattr( - DEFAULT_AUDIO_RESOURCE_POLICY, - "validate_decoded_audio", - lambda *_args: (_ for _ in ()).throw(rejection), - ) + + def reject_decoded( + _self: AudioResourcePolicy, _decoded: object, _sample_rate: object + ) -> np.ndarray: + raise rejection + + monkeypatch.setattr(AudioResourcePolicy, "validate_decoded_audio", reject_decoded) with pytest.raises(AudioResourcePolicyError) as caught: audio_decode.decode_mono_audio(io.BytesIO(b"container")) From 4b3009c20d8353ad0e2b24fd83d3fe6a831147b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:18:25 +0900 Subject: [PATCH 168/448] fix(audio): own one bounded PCM decode port --- .../src/bandscope_analysis/audio_decode.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 services/analysis-engine/src/bandscope_analysis/audio_decode.py diff --git a/services/analysis-engine/src/bandscope_analysis/audio_decode.py b/services/analysis-engine/src/bandscope_analysis/audio_decode.py new file mode 100644 index 000000000..e558ac8b2 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/audio_decode.py @@ -0,0 +1,76 @@ +"""Bounded PCM decode port for the Resource Admission & Decode context. + +The current adapter still delegates to ``librosa`` and therefore remains a +transitional boundary while #1129 removes the libsndfile-backed runtime graph. +Consumers must call this port rather than selecting decoder fallbacks +independently. + +Security Notes: +- The caller-authorized binary handle, container metadata, decoder output, and + third-party decoder exceptions are untrusted. +- Source metadata is admitted before decode and the resulting PCM is revalidated + against the same versioned policy before it can enter MIR or model work. +- Decoder details remain exception causes only; the surfaced failure is the + payload-free canonical resource-policy error. +- This port adds no path, network, subprocess, or credential authority. +""" + +from __future__ import annotations + +import warnings +from typing import BinaryIO, cast + +import librosa +import numpy as np +from numpy.typing import NDArray + +from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_resource_policy import ( + DEFAULT_AUDIO_RESOURCE_POLICY, + AudioResourcePolicy, + AudioResourcePolicyError, +) + +AudioMonoArray = NDArray[np.float32] + + +def _malformed_decode_error() -> AudioResourcePolicyError: + """Build the stable payload-free decoder failure.""" + return AudioResourcePolicyError("malformed_header") + + +def decode_mono_audio( + source: BinaryIO, + *, + policy: AudioResourcePolicy = DEFAULT_AUDIO_RESOURCE_POLICY, +) -> tuple[AudioMonoArray, int]: + """Admit and decode one caller-owned source to bounded mono float32 PCM.""" + preflight_audio_metadata(source, policy) + + try: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") + warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") + decoded, sample_rate = librosa.load( + source, + sr=policy.target_sample_rate, + mono=True, + duration=policy.decode_probe_duration_seconds, + ) + except AudioResourcePolicyError: + raise + except Exception as error: + raise _malformed_decode_error() from error + + try: + pcm = np.ravel(np.asarray(decoded, dtype=np.float32)) + except (OverflowError, TypeError, ValueError) as error: + raise _malformed_decode_error() from error + + try: + policy.validate_decoded_audio(pcm, sample_rate) + except AudioResourcePolicyError: + raise + except Exception as error: + raise _malformed_decode_error() from error + return cast(AudioMonoArray, pcm), int(sample_rate) From c2e6509b47272b4f061468235a4a1828ffbc3cf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:22:04 +0900 Subject: [PATCH 169/448] fix(audio): route MIR consumers through owned decode port --- .../separation/audio_separator.py | 40 ++++--------------- .../bandscope_analysis/temporal/analyzer.py | 40 ++----------------- .../bandscope_analysis/transcription/api.py | 19 ++------- 3 files changed, 14 insertions(+), 85 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 095050c1c..2507bf6cf 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -30,24 +30,18 @@ import logging import os import sys -import warnings from dataclasses import dataclass from pathlib import Path from typing import Any, cast -import librosa import numpy as np -from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_decode import decode_mono_audio from bandscope_analysis.audio_resource_policy import ( DEFAULT_MAX_DURATION_SECONDS, AudioResourcePolicy, ) -from bandscope_analysis.temporal.analyzer import ( - KNOWN_LIBROSA_NUMBA_WARNING_FILTERS, - MAX_AUDIO_FILE_BYTES, - TARGET_SR, -) +from bandscope_analysis.temporal.analyzer import MAX_AUDIO_FILE_BYTES, TARGET_SR from .model import AudioSeparationResult, AudioStemArray, AudioStemName, AudioStemPayload @@ -213,7 +207,7 @@ def _resolve_audio_file(self, audio_path: str | Path) -> Path: return path def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: - """Load and revalidate bounded mono audio before model inference.""" + """Load bounded mono audio through the canonical decoder authority.""" try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size @@ -223,35 +217,15 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: self.resource_policy.validate_encoded_file_bytes(file_size) except ValueError as error: raise ValueError("Audio file is too large for stem separation") from error - preflight_audio_metadata(fileobj, self.resource_policy) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"^audioread" - ) - warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") - for category, message, module in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: - warnings.filterwarnings( - "ignore", - category=category, - message=message, - module=module, - ) - y, sr = librosa.load( - fileobj, - sr=self.resource_policy.target_sample_rate, - mono=True, - duration=self.resource_policy.decode_probe_duration_seconds, - ) + y, sr = decode_mono_audio(fileobj, policy=self.resource_policy) except ValueError: raise except Exception as error: raise ValueError(f"Stem separation decode failed for {path.name}") from error - if isinstance(y, np.ndarray) and y.size == 0: + if y.size == 0: raise ValueError(f"Stem separation decode failed for {path.name}") - validated_audio = self.resource_policy.validate_decoded_audio(y, sr) - return _as_float_array(validated_audio), int(sr) + return _as_float_array(y), int(sr) def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: """Trim or pad a stem to match the source length exactly.""" @@ -271,4 +245,4 @@ def _as_float_array(values: object) -> AudioStemArray: raise ValueError(_MODEL_OUTPUT_ERROR) from error if array.size == 0 or not np.isfinite(array).all(): raise ValueError(_MODEL_OUTPUT_ERROR) - return cast(AudioStemArray, array) + return cast(AudioStemArray, array) \ No newline at end of file diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 110222fa8..584ef6c99 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -4,7 +4,6 @@ import logging import os -import warnings from pathlib import Path from typing import Any @@ -12,7 +11,7 @@ import numpy as np from numpy.typing import NDArray -from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_decode import decode_mono_audio from bandscope_analysis.audio_resource_policy import ( DEFAULT_AUDIO_RESOURCE_POLICY, DEFAULT_MAX_DURATION_SECONDS, @@ -38,7 +37,6 @@ { "Audio file is too large for temporal analysis", "Audio input violates the audio resource policy.", - "Expected numpy array from librosa.load", } ) _MISSING_AUDIO_MESSAGE = "Audio source is unavailable for temporal analysis." @@ -124,38 +122,8 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: self.resource_policy.validate_encoded_file_bytes(file_size) except ValueError as error: raise ValueError("Audio file is too large for temporal analysis") from error - preflight_audio_metadata(fileobj, self.resource_policy) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=DeprecationWarning, module=r"^audioread" - ) - warnings.filterwarnings("ignore", category=FutureWarning, module=r"^audioread") - - # Keep the loader's known third-party churn quiet without hiding - # unrelated decoder warnings that tests and callers should see. - for category, message, module in KNOWN_LIBROSA_NUMBA_WARNING_FILTERS: - warnings.filterwarnings( - "ignore", - category=category, - message=message, - module=module, - ) - # Decode one sample beyond the accepted duration so longer - # sources fail closed instead of becoming silently truncated. - y, sr = librosa.load( - fileobj, - sr=self.resource_policy.target_sample_rate, - mono=True, - duration=self.resource_policy.decode_probe_duration_seconds, - ) - - # Preserve the established diagnostic for decoder contract violations - # before applying the canonical numeric policy. - if not isinstance(y, np.ndarray): - raise ValueError("Expected numpy array from librosa.load") - - y_array = self.resource_policy.validate_decoded_audio(y, sr) + y_array, sr = decode_mono_audio(fileobj, policy=self.resource_policy) + duration = float(librosa.get_duration(y=y_array, sr=sr)) logger.info("Extracting tempo and beat tracking...") @@ -182,4 +150,4 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: except Exception as error: logger.error("Temporal analysis failed (%s).", type(error).__name__) - raise ValueError(_safe_temporal_failure_message(error)) from error + raise ValueError(_safe_temporal_failure_message(error)) from error \ No newline at end of file diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index b4090269f..8be079c08 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -3,14 +3,13 @@ from __future__ import annotations import io -import warnings from dataclasses import dataclass import librosa import numpy as np from numpy.typing import NDArray -from bandscope_analysis.audio_metadata import preflight_audio_metadata +from bandscope_analysis.audio_decode import decode_mono_audio from bandscope_analysis.audio_resource_policy import AudioResourcePolicy TARGET_SR = 22050 @@ -52,19 +51,7 @@ def transcribe_bass_stem(stem_data: bytes) -> list[NoteEvent]: raise ValueError("Stem data is too large for transcription.") source = io.BytesIO(stem_data) - preflight_audio_metadata(source, TRANSCRIPTION_RESOURCE_POLICY) - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"^audioread") - y, sr = librosa.load( - source, - sr=TARGET_SR, - mono=True, - duration=TRANSCRIPTION_RESOURCE_POLICY.decode_probe_duration_seconds, - ) - - y_array = np.asarray( - TRANSCRIPTION_RESOURCE_POLICY.validate_decoded_audio(y, sr), dtype=np.float32 - ) + y_array, sr = decode_mono_audio(source, policy=TRANSCRIPTION_RESOURCE_POLICY) if y_array.size == 0 or float(np.max(np.abs(y_array))) < MIN_SIGNAL_PEAK: return [] @@ -184,4 +171,4 @@ def _merge_adjacent_equal_pitches(events: list[NoteEvent]) -> list[NoteEvent]: ) else: merged.append(event) - return merged + return merged \ No newline at end of file From 3a76907dd3df603cc65188fa6d5c97d583f744e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:37:50 +0900 Subject: [PATCH 170/448] test(audio): follow the owned decode-port preflight seam --- .../tests/test_audio_resource_policy_integration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy_integration.py b/services/analysis-engine/tests/test_audio_resource_policy_integration.py index 1f58720f1..72ce8d0bd 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy_integration.py +++ b/services/analysis-engine/tests/test_audio_resource_policy_integration.py @@ -75,7 +75,7 @@ def test_temporal_decoder_probes_one_sample_past_duration_limit_and_fails_closed source = tmp_path / "overlong.wav" source.write_bytes(b"bounded") monkeypatch.setattr( - "bandscope_analysis.temporal.analyzer.preflight_audio_metadata", + "bandscope_analysis.audio_decode.preflight_audio_metadata", lambda *_args, **_kwargs: None, ) captured: dict[str, object] = {} @@ -149,7 +149,7 @@ def test_stem_decoder_probes_one_sample_past_duration_limit_and_fails_closed( source = tmp_path / "overlong.wav" source.write_bytes(b"bounded") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + "bandscope_analysis.audio_decode.preflight_audio_metadata", lambda *_args, **_kwargs: None, ) captured: dict[str, object] = {} @@ -217,7 +217,7 @@ def test_stem_decoder_rejects_nonfinite_decoded_output_before_model( source = tmp_path / "nonfinite.wav" source.write_bytes(b"bounded") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + "bandscope_analysis.audio_decode.preflight_audio_metadata", lambda *_args, **_kwargs: None, ) monkeypatch.setattr( From 38fb6d0a15a8cac3e51fa001adbc7a6b46246956 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:06:15 +0900 Subject: [PATCH 171/448] test(project): reproduce first-save directory durability gap --- .../project_persistence_atomic_publication.rs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs index c74423707..67c71fe38 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_atomic_publication.rs @@ -1,3 +1,38 @@ +#[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"); @@ -8,3 +43,115 @@ fn hard_link_fallback_never_reserves_the_final_path_with_an_empty_file() { "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"); +} From 46f28ee2892492070d67f68ecfba0f091d70bb92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:15:14 +0900 Subject: [PATCH 172/448] fix(project): require first-save directory durability --- .../src-tauri/src/project_persistence.rs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index e454263e4..2a95e96af 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1146,7 +1146,11 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// 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. Filesystems without the required native primitive fail closed. These checks do not claim +/// 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. @@ -1163,6 +1167,24 @@ pub(crate) fn publish_new_project_file_with_linker( ) -> 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()); @@ -1227,7 +1249,10 @@ where } match rename_noreplace(&stage, target) { - Ok(()) => return Ok(()), + 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()); @@ -1239,8 +1264,10 @@ where } } - // Both names reference the already-synced inode at this point. Cleanup failure does not make the - // published target partial, so do not report a false save failure after publication succeeded. + // 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(()) } @@ -1640,7 +1667,7 @@ mod tests { ); assert!(!stage.exists(), "the candidate should be cleaned"); assert!(!journal.exists(), "the recovery journal should be cleaned"); - fs::remove_dir_all(root).expect("test directory should be removable"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); } #[cfg(any(target_os = "linux", target_os = "macos"))] From fef65c229d117c4ac5e9cb0261868f977fd3610b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:12:08 +0900 Subject: [PATCH 173/448] fix(ci): pin project persistence Windows Rust toolchain --- .github/workflows/project-persistence-windows.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index 0cb85f410..57e4d5e88 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -36,12 +36,12 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - 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 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests + run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests From c5cc94feda19f8c75504abab9731717963309932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:53:54 +0900 Subject: [PATCH 174/448] fix(audio): document decode-port regressions --- .../tests/test_audio_decode_port.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py index cbeb25852..e1c4682a6 100644 --- a/services/analysis-engine/tests/test_audio_decode_port.py +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -1,3 +1,8 @@ +"""Contract tests for the canonical local-audio decode port. + +These regressions keep resource admission, decoder failure redaction, and decoded-output validation behind one owned boundary. +""" + from __future__ import annotations import io @@ -16,6 +21,10 @@ def test_decode_mono_audio_preflights_then_validates_one_owned_decode( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Keep preflight, one decode, and decoded validation in strict order. + + The decode port must own the sequence so downstream analyzers cannot bypass or duplicate resource admission. + """ source = io.BytesIO(b"container") calls: list[tuple[str, object]] = [] decoder_output = np.array([[0.25, -0.5]], dtype=np.float64) @@ -62,6 +71,10 @@ def validate(self: AudioResourcePolicy, decoded: object, sample_rate: object) -> def test_decode_mono_audio_preserves_resource_policy_rejection( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Propagate the canonical preflight rejection without invoking a decoder. + + A rejected source must not consume additional decode resources or lose its typed policy reason. + """ rejection = AudioResourcePolicyError("duration_exceeded") def reject(_source: object, _policy: object) -> None: @@ -83,6 +96,10 @@ def reject(_source: object, _policy: object) -> None: def test_decode_mono_audio_redacts_third_party_decoder_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Map third-party decoder details to a payload-safe policy error. + + Native paths or token-shaped details may remain only in the exception cause for local debugging, never in buyer-facing error text. + """ secret_detail = "/Users/alice/Music/private.m4a token=secret" monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) monkeypatch.setattr( @@ -102,6 +119,10 @@ def test_decode_mono_audio_redacts_third_party_decoder_failure( def test_decode_mono_audio_redacts_malformed_decoder_output( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Reject decoder output that cannot be normalized into bounded PCM. + + Malformed third-party values must fail at the decode boundary rather than escaping into MIR analyzers. + """ monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) monkeypatch.setattr( audio_decode.librosa, @@ -118,6 +139,10 @@ def test_decode_mono_audio_redacts_malformed_decoder_output( def test_decode_mono_audio_preserves_decoded_policy_rejection( monkeypatch: pytest.MonkeyPatch, ) -> None: + """Preserve rejection identity from decoded-audio resource validation. + + The decode port must not collapse a precise post-decode budget failure into a generic malformed-container error. + """ rejection = AudioResourcePolicyError("decoded_sample_count_exceeded") monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) monkeypatch.setattr( From 93e9e80fa13d93692fdbd8d7d9acd10714ee8e8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:07:50 +0900 Subject: [PATCH 175/448] test(project): expose current rehearsal contract drift --- .../tests/project_persistence_contract.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/desktop/core/tests/project_persistence_contract.rs 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..65bee29b8 --- /dev/null +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -0,0 +1,119 @@ +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": [], + "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["collaboration"]["assignments"][0]["roleId"], json!("bass-guitar")); +} From becb11c0a75059fdf5889b8181c962a58de468e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:12:55 +0900 Subject: [PATCH 176/448] test(ci): expose Windows persistence trigger gap --- ...est_project_persistence_workflow_policy.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 services/analysis-engine/tests/test_project_persistence_workflow_policy.py 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..9c960c93b --- /dev/null +++ b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py @@ -0,0 +1,26 @@ +"""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/tests/project_persistence*.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}" From 5b397ce9cc8bf5aa8bb1cb61a826a2f0091587b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:13:09 +0900 Subject: [PATCH 177/448] fix(ci): cover Project Persistence contract inputs on Windows --- .github/workflows/project-persistence-windows.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index 57e4d5e88..1b7af6302 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -6,6 +6,13 @@ on: - develop - main paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/tests/project_persistence*.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" @@ -14,6 +21,13 @@ on: - develop - main paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/tests/project_persistence*.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" From 819d8af80e425dc5627d86659a5fc97ec90c2767 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:11:34 +0900 Subject: [PATCH 178/448] fix(project): preserve shared rehearsal song fields --- apps/desktop/core/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 81cc9cdee..9d481f8e5 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -131,6 +131,8 @@ pub struct RehearsalSongPayload { sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] + collaboration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } @@ -152,6 +154,49 @@ where } } +#[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, skip_serializing_if = "Option::is_none")] + role_id: Option, + status: String, +} + +#[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, skip_serializing_if = "Option::is_none")] + role_id: Option, + status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalApprovalPayload { + id: String, + scope: String, + owner: String, + status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCollaborationPayload { + sync_mode: String, + 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; @@ -213,6 +258,15 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -220,14 +274,22 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + practice_progress: Option, } #[derive(Clone, Debug, Serialize)] @@ -1428,4 +1490,4 @@ mod tests { let _ = std::fs::remove_dir_all(scores_root); let _ = std::fs::remove_dir_all(outside_root); } -} +} \ No newline at end of file From 6bcdf160a7e95cc540d96e49e25868c19a438106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:13:38 +0900 Subject: [PATCH 179/448] test(project): reject invalid shared collaboration states --- .../tests/project_persistence_contract.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs index 65bee29b8..4d79a00e6 100644 --- a/apps/desktop/core/tests/project_persistence_contract.rs +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -117,3 +117,26 @@ fn project_persistence_round_trips_current_shared_song_fields() { assert_eq!(round_trip["sections"][0]["roles"][0]["practiceProgress"], json!(45)); 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()); +} \ No newline at end of file From a1cf37ea98db2f8024ca710d563d879c04204961 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:16:17 +0900 Subject: [PATCH 180/448] fix(project): enforce shared collaboration domains --- apps/desktop/core/src/lib.rs | 65 ++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 9d481f8e5..6e4953505 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -154,6 +154,37 @@ where } } +#[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 { @@ -163,7 +194,7 @@ pub struct RehearsalAssignmentPayload { section_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] role_id: Option, - status: String, + status: RehearsalAssignmentStatusPayload, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -175,7 +206,7 @@ pub struct RehearsalCommentPayload { section_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] role_id: Option, - status: String, + status: RehearsalCommentStatusPayload, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -184,13 +215,13 @@ pub struct RehearsalApprovalPayload { id: String, scope: String, owner: String, - status: String, + status: RehearsalApprovalStatusPayload, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalCollaborationPayload { - sync_mode: String, + sync_mode: RehearsalCollaborationSyncModePayload, sync_note: String, assignments: Vec, comments: Vec, @@ -267,6 +298,24 @@ pub struct TranscriptionNotePayload { 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 = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -288,7 +337,11 @@ pub struct RehearsalRolePayload { overlap_warnings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] transcription: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_practice_progress", + skip_serializing_if = "Option::is_none" + )] practice_progress: Option, } @@ -1490,4 +1543,4 @@ mod tests { let _ = std::fs::remove_dir_all(scores_root); let _ = std::fs::remove_dir_all(outside_root); } -} \ No newline at end of file +} From c461d4664fd372964306328962c44bfea76b7286 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:17:33 +0900 Subject: [PATCH 181/448] docs(project): trace shared-song persistence contract --- docs/engineering/local-project-format.md | 13 ++++++- ...roject-persistence-shared-song-contract.md | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 docs/traceability/project-persistence-shared-song-contract.md diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 7d34e3b30..ee5d43ea7 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -24,11 +24,20 @@ The primary data structure for a `.bscope` file is the `RehearsalSong` type from "format": "cue-sheet", "headline": "string", "focusSections": ["string"] + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "string", + "assignments": [ ... ], + "comments": [ ... ], + "approvals": [ ... ] } } } ``` +`tempo` and `collaboration` are optional. 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 version is independent of the application package version. The v1 reader rejects unknown envelope fields and returns an explicit unsupported-version error for a well-formed future version. The checked-in golden fixture is `apps/desktop/core/testdata/project-v1.json`. ### Sections and Roles @@ -83,13 +92,15 @@ By retaining `manualOverrides`, BandScope can distinguish between original model 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. +2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. Collaboration state tokens and `practiceProgress` use the same accepted domains as the shared renderer contract. 3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks. ## Current boundary and next migration slices Version 1 deliberately keeps the existing validated `RehearsalSong` as the compatibility view. Source references, derived analysis artifacts, user decisions, portable handoff data, UI preferences, and volatile player state are not fabricated or written into untyped bags. Their typed promotion, bounded autosave journal, backup rotation, migration receipts, and accessible restore/compare/discard flow remain the next #962 slices. Player state must use this authority after the transport state machine is stable; it must not create a second localStorage or session persistence authority. +A selected playback source must be persisted as a stable project semantic such as `full_mix`, `vocals`, `bass`, `drums`, or `other`, never as a revocable `bandscope-playback` authority. Reload must resolve that semantic against current native availability and fail closed to Full mix if the prior source is unavailable. + ## Extensibility Future updates to the `.bscope` format must add an ordered migration from the prior envelope, validate a copy before publication, retain the prior known-good artifact, and update the machine-verifiable 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-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md new file mode 100644 index 000000000..c9aa56e5a --- /dev/null +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -0,0 +1,34 @@ +# 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. A second review found that representing collaboration state tokens as unrestricted Rust strings and `practiceProgress` as an unconstrained `u8` would make the native boundary more permissive than the shared contract. + +## 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. + +## 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. +- Domain RED: `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` must fail closed. +- Domain fix: `a1cf37ea98db2f8024ca710d563d879c04204961` replaces unrestricted collaboration state strings with serde enums and bounds `practiceProgress` to an integer from 0 through 100. + +The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`: `syncMode` accepts `local_only | planned_cloud`; assignment status accepts `todo | in_progress | ready | blocked`; comment status accepts `open | resolved`; approval status accepts `pending | approved | changes_requested`; and `practiceProgress`, when present, is an integer from 0 through 100. + +## 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 collaboration states as `String`:** rejected because malformed or future tokens could be persisted as if they were current domain values. +- **Clamp out-of-range practice progress:** rejected because changing user/project data on load hides corruption or contract drift; malformed input must fail closed. + +## Effects and remaining risks + +A current shared rehearsal song can now cross the native Project Persistence boundary without dropping the newly covered fields, and the newly introduced collaboration/progress domains match the renderer validator. This does not complete #962. Existing older native DTO fields still include stringly typed domain values whose exact renderer-domain parity needs a separate focused audit; optional-field null semantics also need explicit cross-language contract tests before claiming full schema equivalence. 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. From ed61d1c5f10e2baa4290fb40d692b82fb7dde500 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:20:16 +0900 Subject: [PATCH 182/448] test(project): reject null optional shared fields --- .../tests/project_persistence_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs index 4d79a00e6..a60085ee8 100644 --- a/apps/desktop/core/tests/project_persistence_contract.rs +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -139,4 +139,25 @@ fn project_persistence_rejects_invalid_shared_collaboration_states_and_progress( 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_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_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 From 8b4ae848ec360a5af42b50076af15b643ae5275e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:25:59 +0900 Subject: [PATCH 183/448] fix(project): reject null optional shared fields --- apps/desktop/core/src/lib.rs | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 6e4953505..34cc72902 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -130,9 +130,17 @@ pub struct RehearsalSongPayload { 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, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] score_attachments: Option>, } @@ -154,6 +162,14 @@ where } } +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 { @@ -192,7 +208,11 @@ pub struct RehearsalAssignmentPayload { assignee: String, summary: String, section_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] role_id: Option, status: RehearsalAssignmentStatusPayload, } @@ -204,7 +224,11 @@ pub struct RehearsalCommentPayload { author: String, body: String, section_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] role_id: Option, status: RehearsalCommentStatusPayload, } @@ -323,7 +347,11 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] harmonic_explanation: Option, cue: CuePayload, range: RangePayload, @@ -331,11 +359,19 @@ pub struct RehearsalRolePayload { rehearsal_priority: String, simplification: String, setup_note: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_present_optional", + skip_serializing_if = "Option::is_none" + )] transcription: Option>, #[serde( default, From ed9abedf0e5069fa93780fa3440ca91500cbdd93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:26:27 +0900 Subject: [PATCH 184/448] test(project): cover null optional attachment list --- apps/desktop/core/tests/project_persistence_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs index a60085ee8..2b655c606 100644 --- a/apps/desktop/core/tests/project_persistence_contract.rs +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -147,6 +147,10 @@ fn project_persistence_rejects_explicit_null_for_optional_shared_fields() { 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()); From e8f0849bed6c75fe76167b17f0255eb3838f6aa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:26:49 +0900 Subject: [PATCH 185/448] docs(project): trace optional-field null parity --- .../project-persistence-shared-song-contract.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index c9aa56e5a..41a4fb784 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -2,7 +2,7 @@ ## 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. A second review found that representing collaboration state tokens as unrestricted Rust strings and `practiceProgress` as an unconstrained `u8` would make the native boundary more permissive than the shared contract. +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 two inverse drift modes as well: unrestricted Rust strings / unconstrained progress could accept invalid domain values, and Rust `Option` would silently accept explicit JSON `null` where the TypeScript validator accepts only omission or a value of the declared type. ## Constraints @@ -17,8 +17,10 @@ The desktop shared contract already permits collaboration data and role-level re - Structural fix: `819d8af80e425dc5627d86659a5fc97ec90c2767` adds typed native DTOs for those fields while retaining the existing v1/tempo/unknown-field invariants. - Domain RED: `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` must fail closed. - Domain 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`. -The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`: `syncMode` accepts `local_only | planned_cloud`; assignment status accepts `todo | in_progress | ready | blocked`; comment status accepts `open | resolved`; approval status accepts `pending | approved | changes_requested`; and `practiceProgress`, when present, is an integer from 0 through 100. +The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`: `syncMode` accepts `local_only | planned_cloud`; assignment status accepts `todo | in_progress | ready | blocked`; comment status accepts `open | resolved`; approval status accepts `pending | approved | changes_requested`; and `practiceProgress`, when present, is an integer from 0 through 100. Its optional fields test `!== undefined` before validating the concrete declared type, so explicit `null` is invalid rather than another spelling of absence. ## Alternatives rejected @@ -26,9 +28,10 @@ The shared renderer authority is `packages/shared-types/src/index.ts` on protect - **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 collaboration states as `String`:** rejected because malformed or future tokens could be persisted as if they were current domain values. - **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. ## Effects and remaining risks -A current shared rehearsal song can now cross the native Project Persistence boundary without dropping the newly covered fields, and the newly introduced collaboration/progress domains match the renderer validator. This does not complete #962. Existing older native DTO fields still include stringly typed domain values whose exact renderer-domain parity needs a separate focused audit; optional-field null semantics also need explicit cross-language contract tests before claiming full schema equivalence. Autosave, backup rotation, global startup recovery, deterministic migrations beyond v1, fault injection, and selected-playback-source persistence/reload remain open. +A current shared rehearsal song can now cross the native Project Persistence boundary without dropping the newly covered fields; collaboration/progress domains and omission-versus-null semantics for the newly touched optionals match the renderer validator. This does not complete #962. Existing older native DTO fields still include stringly typed domain values whose exact renderer-domain parity needs a separate focused audit. Numeric transcription bounds and other legacy field invariants also need an evidence-driven cross-language pass rather than speculative tightening. 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. From 2b0a47e6305b7b7a3e87857335d0f36dfabc9712 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:01:24 +0900 Subject: [PATCH 186/448] test(project): reject invalid shared closed domains --- .../tests/project_persistence_contract.rs | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs index 2b655c606..694b498f2 100644 --- a/apps/desktop/core/tests/project_persistence_contract.rs +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -42,7 +42,17 @@ fn current_rehearsal_song() -> Value { "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": [], + "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 } @@ -115,6 +125,7 @@ fn project_persistence_round_trips_current_shared_song_fields() { 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")); } @@ -141,6 +152,57 @@ fn project_persistence_rejects_invalid_shared_collaboration_states_and_progress( assert!(project_payload_from_content(&invalid_practice_progress.to_string()).is_err()); } +#[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(); From 96d66ed6f5fad918b0ddef8a1e6494b76f8bafd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:06:00 +0900 Subject: [PATCH 187/448] fix(project): align native closed domains with shared contract --- apps/desktop/core/src/lib.rs | 103 +++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 34cc72902..aaf2fc812 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -275,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, } @@ -302,15 +325,35 @@ 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)] @@ -340,12 +383,28 @@ where } } +#[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)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { id: String, name: String, - role_type: String, + role_type: RehearsalRoleTypePayload, harmony: HarmonyPayload, #[serde( default, @@ -356,7 +415,7 @@ pub struct RehearsalRolePayload { cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, - rehearsal_priority: String, + rehearsal_priority: RehearsalPriorityPayload, simplification: String, setup_note: String, #[serde( @@ -423,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, @@ -435,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, } From 2605f08991d306e3d2418bc237a70cf01553b7e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:06:33 +0900 Subject: [PATCH 188/448] docs(traceability): record closed-domain parity repair --- .../project-persistence-shared-song-contract.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index 41a4fb784..cb04586b2 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -2,7 +2,7 @@ ## 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 two inverse drift modes as well: unrestricted Rust strings / unconstrained progress could accept invalid domain values, and Rust `Option` would silently accept explicit JSON `null` where the TypeScript validator accepts only omission or a value of the declared type. +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 @@ -10,28 +10,32 @@ The desktop shared contract already permits collaboration data and role-level re - 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. -- Domain RED: `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` must fail closed. -- Domain fix: `a1cf37ea98db2f8024ca710d563d879c04204961` replaces unrestricted collaboration state strings with serde enums and bounds `practiceProgress` to an integer from 0 through 100. +- 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. -The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`: `syncMode` accepts `local_only | planned_cloud`; assignment status accepts `todo | in_progress | ready | blocked`; comment status accepts `open | resolved`; approval status accepts `pending | approved | changes_requested`; and `practiceProgress`, when present, is an integer from 0 through 100. Its optional fields test `!== undefined` before validating the concrete declared type, so explicit `null` is invalid rather than another spelling of absence. +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 collaboration states as `String`:** rejected because malformed or future tokens could be persisted as if they were current domain values. +- **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. ## 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 domains and omission-versus-null semantics for the newly touched optionals match the renderer validator. This does not complete #962. Existing older native DTO fields still include stringly typed domain values whose exact renderer-domain parity needs a separate focused audit. Numeric transcription bounds and other legacy field invariants also need an evidence-driven cross-language pass rather than speculative tightening. Autosave, backup rotation, global startup recovery, deterministic migrations beyond v1, fault injection, and selected-playback-source persistence/reload remain open. +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. This does not complete #962. Numeric transcription bounds and other legacy numeric/string invariants still need evidence-driven cross-language tests rather than speculative tightening. 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. From f8c30150375b39d54e1775d941f6515d2686410c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:08:31 +0900 Subject: [PATCH 189/448] test(project): cover every shared closed-domain token --- .../tests/project_persistence_contract.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs index 694b498f2..11db3b2a0 100644 --- a/apps/desktop/core/tests/project_persistence_contract.rs +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -152,6 +152,73 @@ fn project_persistence_rejects_invalid_shared_collaboration_states_and_progress( 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(); From aff7ecc4547f771eff6a0fc081e07077bc555f20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:09:33 +0900 Subject: [PATCH 190/448] docs(traceability): add positive closed-domain evidence --- docs/traceability/project-persistence-shared-song-contract.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index cb04586b2..a49a87b8b 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -22,6 +22,7 @@ The desktop shared contract already permits collaboration data and role-level re - 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. 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. @@ -36,6 +37,6 @@ The shared renderer authority is `packages/shared-types/src/index.ts` on protect ## 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. This does not complete #962. Numeric transcription bounds and other legacy numeric/string invariants still need evidence-driven cross-language tests rather than speculative tightening. Autosave, backup rotation, global startup recovery, deterministic migrations beyond v1, fault injection, and selected-playback-source persistence/reload remain open. +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. 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. From d7886876b285f16ceda83ff5e0dd848e31cf7f97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:03:46 +0900 Subject: [PATCH 191/448] test(docs): enforce Security Notes in traceability records --- scripts/checks/verify_security_notes.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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)) From 0185267ab819dd4b9ac1352f5fce1df8e2a7a782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:04:22 +0900 Subject: [PATCH 192/448] docs(project): add persistence Security Notes --- ...roject-persistence-shared-song-contract.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index a49a87b8b..7052226df 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -23,6 +23,7 @@ The desktop shared contract already permits collaboration data and role-level re - 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. 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. @@ -40,3 +41,33 @@ The shared renderer authority is `packages/shared-types/src/index.ts` on protect 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. 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, 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` now 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. \ No newline at end of file From a7c86be8e20895e3baebee44d33ef765e0837b5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:06:46 +0900 Subject: [PATCH 193/448] test(project): name binary project limit precisely --- .../tests/project_persistence_overwrite.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs index 6171225ce..7f26a4be6 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_overwrite.rs @@ -223,3 +223,29 @@ fn failed_windows_replace_removes_the_candidate_stage() { .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"); +} From 04e19ef6d19aced87e22015e4ec165cbce89f1d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:09:59 +0900 Subject: [PATCH 194/448] fix(project): name 5 MiB limit accurately --- apps/desktop/src-tauri/src/project_persistence.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 2a95e96af..ec56f3e75 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -11,7 +11,7 @@ const PROJECT_EXISTS_ERROR: &str = "Project file already exists. Choose a new fi 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 5MB limit)"; +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)] @@ -1516,7 +1516,7 @@ mod tests { 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 5MB limit)"); + assert_eq!(error, "Project file is too large (exceeds 5 MiB limit)"); assert!(!target.exists()); assert_eq!( fs::read_dir(&root) @@ -1592,7 +1592,7 @@ mod tests { 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 5MB limit)"); + assert_eq!(error, "Project file is too large (exceeds 5 MiB limit)"); fs::remove_dir_all(root).expect("test directory should be removable"); } @@ -1704,7 +1704,7 @@ mod tests { 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("test directory should be removable"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); } #[cfg(any(target_os = "linux", target_os = "macos", windows))] @@ -1727,7 +1727,7 @@ mod tests { fs::read(&target).expect("target should remain readable"), br#"{"id":"selected"}"# ); - fs::remove_dir_all(root).expect("test directory should be removable"); + fs::remove_dir_all(root).expect("fixture directory should be removable"); } #[test] From 73d6a80183c19166b75be05f9286bee3769069e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:10:21 +0900 Subject: [PATCH 195/448] docs(project): state 5 MiB limit precisely --- docs/engineering/local-project-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index ee5d43ea7..973082e22 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -91,7 +91,7 @@ By retaining `manualOverrides`, BandScope can distinguish between original model ## 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. +1. **Size Limits**: The project file must not exceed an upper bound (currently 5 MiB, implemented as `5 * 1024 * 1024` bytes in the Tauri backend) to prevent memory exhaustion. 2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. Collaboration state tokens and `practiceProgress` use the same accepted domains as the shared renderer contract. 3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks. From 1d2e0867d473b46869b845c9d07369822e667a5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:10:54 +0900 Subject: [PATCH 196/448] docs(traceability): record project size unit repair --- .../project-persistence-shared-song-contract.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index 7052226df..045b6767b 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -24,6 +24,8 @@ The desktop shared contract already permits collaboration data and role-level re - 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. @@ -35,10 +37,11 @@ The shared renderer authority is `packages/shared-types/src/index.ts` on protect - **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. 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. +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. @@ -62,7 +65,7 @@ Malformed envelopes, unsupported versions, invalid shared-domain tokens, explici ### 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, 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` now also treats traceability records as governed Security Notes documents so later edits cannot silently drop this boundary. +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 @@ -70,4 +73,4 @@ The realistic threats are malformed or future project payloads being accepted as ### 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. \ No newline at end of file +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. From 86207ea0459f1a6e27e80f571ad5d6462a0d6fab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:01:24 +0900 Subject: [PATCH 197/448] test(project): require v2 playback preference migration --- .../project_format_v2_playback_preference.rs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 apps/desktop/core/tests/project_format_v2_playback_preference.rs 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..d8be45eb9 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v2_playback_preference.rs @@ -0,0 +1,120 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, 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://")); +} From be4ce61f9a865229aad9b46ad27adb79b1028258 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:07:45 +0900 Subject: [PATCH 198/448] feat(project): migrate durable playback preference to v2 --- apps/desktop/core/src/core.rs | 1663 ++++++++++++++++++++++ apps/desktop/core/src/lib.rs | 1674 +---------------------- apps/desktop/core/src/project_format.rs | 153 +++ 3 files changed, 1829 insertions(+), 1661 deletions(-) create mode 100644 apps/desktop/core/src/core.rs create mode 100644 apps/desktop/core/src/project_format.rs diff --git a/apps/desktop/core/src/core.rs b/apps/desktop/core/src/core.rs new file mode 100644 index 000000000..aaf2fc812 --- /dev/null +++ b/apps/desktop/core/src/core.rs @@ -0,0 +1,1663 @@ +//! Pure, GUI-independent logic for the BandScope desktop app. +//! +//! This crate holds every payload contract, validation guard, and process +//! helper that does not depend on Tauri or the WebView runtime. Keeping it +//! free of `tauri`/`wry` lets the full unit-test suite build and run (and be +//! measured for coverage) on any platform without a windowing system or a +//! bundled frontend. + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; +use time::OffsetDateTime; + +#[derive(Clone)] +pub struct AppState(pub Arc); + +pub struct AppStateInner { + pub next_job: AtomicU64, + pub in_flight_jobs: AtomicUsize, + pub jobs: Mutex>, + pub bootstrap_sources: Mutex>, +} + +pub const MAX_IN_FLIGHT_JOBS: usize = 2; + +pub const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); + +pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); + +pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; + +pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; + +pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); + +pub const MAX_YOUTUBE_URL_LENGTH: usize = 2000; + +pub const MAX_SCORE_PDF_BYTES: u64 = 25 * 1024 * 1024; + +pub const PDF_MAGIC: &[u8] = b"%PDF-"; + +impl Default for AppState { + fn default() -> Self { + Self(Arc::new(AppStateInner { + next_job: AtomicU64::new(1), + in_flight_jobs: AtomicUsize::new(0), + jobs: Mutex::new(HashMap::new()), + bootstrap_sources: Mutex::new(HashMap::new()), + })) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobRequest { + pub source_kind: String, + pub project_id: Option, + pub source_label: String, + pub role_focus: Vec, + pub local_source: Option, + pub cache_root: Option, + pub temp_root: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobErrorCode { + InvalidRequest, + NotFound, + EngineUnavailable, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobError { + pub code: AnalysisJobErrorCode, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobState { + Queued, + Running, + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobStage { + Queued, + Decode, + Separate, + Analyze, + Persist, + Ready, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisCacheStatus { + Disabled, + Miss, + Hit, + Stored, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +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, + 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. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScoreAttachmentMetadataPayload { + id: String, + 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: 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: CueKindPayload, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RangePayload { + lowest_note: String, + highest_note: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HarmonyPayload { + chord: String, + function_label: 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: 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)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalRolePayload { + id: String, + name: 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: 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)] +#[serde(rename_all = "camelCase")] +pub struct SectionTimeRangePayload { + start: u32, + end: u32, +} + +impl<'de> Deserialize<'de> for SectionTimeRangePayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct RawSectionTimeRangePayload { + start: u32, + end: u32, + } + + let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; + if raw.end <= raw.start { + return Err(serde::de::Error::custom( + "section timeRange end must be greater than start", + )); + } + + Ok(Self { + start: raw.start, + end: raw.end, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartGraphNodePayload { + role_id: String, + is_active: bool, + handoff_to: Vec, + 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: SectionFormLabelPayload, + groove: String, + time_range: SectionTimeRangePayload, + confidence: ConfidencePayload, + roles: Vec, + 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: ExportFormatPayload, + headline: String, + focus_sections: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobStatus { + pub job_id: String, + pub state: AnalysisJobState, + pub requested_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAudioSourcePayload { + pub source_path: String, + pub file_name: String, + pub extension: String, + pub file_size_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectBootstrapSummaryPayload { + pub project_id: String, + pub source_mode: String, + pub project_root: String, + pub cache_root: String, + pub temp_root: String, + pub source: LocalAudioSourcePayload, +} + +pub fn next_project_id(state: &AppState) -> String { + format!( + "project-{}-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos(), + state.0.next_job.fetch_add(1, Ordering::Relaxed) + ) +} + +pub fn youtube_source_from_metadata( + metadata: &Value, + cache_root: &Path, +) -> Result { + let filepath = metadata + .get("filepath") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; + let title = metadata + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown YouTube Audio"); + let path = Path::new(filepath); + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let canonical_cache_root = cache_root + .canonicalize() + .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; + #[cfg(coverage)] + let canonical = path + .canonicalize() + .expect("downloaded audio path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = path + .canonicalize() + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !canonical.starts_with(&canonical_cache_root) { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let file_metadata = link_metadata; + if !file_metadata.is_file() || file_metadata.len() == 0 { + return Err("YouTube import returned an invalid audio file.".to_string()); + } + + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { + return Err("YouTube import returned an unsupported audio format.".to_string()); + } + + let safe_title: String = title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', + c if c.is_control() => '_', + c => c, + }) + .take(100) + .collect(); + let safe_title = if safe_title.is_empty() { + "youtube_audio".to_string() + } else { + safe_title + }; + + Ok(LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: format!("{safe_title}.{extension}"), + extension, + file_size_bytes: file_metadata.len(), + }) +} + +pub fn is_supported_youtube_url(url: &str) -> bool { + if url.len() > MAX_YOUTUBE_URL_LENGTH { + return false; + } + + let parsed_url = match url::Url::parse(url) { + Ok(u) => u, + Err(_) => return false, + }; + if parsed_url.scheme() != "https" { + return false; + } + + let host = parsed_url.host_str().unwrap_or("").to_lowercase(); + if host == "youtu.be" { + let mut segments = parsed_url + .path_segments() + .expect("https URLs should expose path segments") + .filter(|segment| !segment.is_empty()); + let Some(video_id) = segments.next() else { + return false; + }; + return is_youtube_video_id(video_id) && segments.next().is_none(); + } + + if host == "youtube.com" || host == "www.youtube.com" { + if parsed_url.path() != "/watch" { + return false; + } + let mut video_ids = parsed_url + .query_pairs() + .filter(|(key, _)| key == "v") + .map(|(_, value)| value); + return match (video_ids.next(), video_ids.next()) { + (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), + _ => false, + }; + } + + false +} + +pub fn youtube_missing_metadata_error(_parsed: &Value) -> String { + "YouTube import reported ok but missing metadata.".to_string() +} + +pub fn wait_for_process_output( + mut command: Command, + timeout: Duration, + poll_interval: Duration, + timeout_message: &str, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "Failed to start YouTube import process.".to_string())?; + let stdout = child + .stdout + .take() + .expect("stdout should be piped for YouTube import process"); + let stderr = child + .stderr + .take() + .expect("stderr should be piped for YouTube import process"); + let stdout_reader = thread::spawn(move || { + let mut reader = stdout; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let stderr_reader = thread::spawn(move || { + let mut reader = stderr; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let deadline = Instant::now() + timeout; + + loop { + let process_status = { + #[cfg(coverage)] + { + child + .try_wait() + .expect("YouTube process status polling should not fail under coverage") + } + #[cfg(not(coverage))] + { + match child.try_wait() { + Ok(status) => status, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("Failed to execute YouTube import process.".to_string()); + } + } + } + }; + + match process_status { + Some(status) => { + #[cfg(coverage)] + let stdout = stdout_reader + .join() + .expect("stdout reader should not panic") + .expect("stdout reader should read process output"); + #[cfg(not(coverage))] + let stdout = stdout_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + #[cfg(coverage)] + let stderr = stderr_reader + .join() + .expect("stderr reader should not panic") + .expect("stderr reader should read process output"); + #[cfg(not(coverage))] + let stderr = stderr_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + return Ok(std::process::Output { + status, + stdout, + stderr, + }); + } + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(timeout_message.to_string()); + } + thread::sleep(poll_interval); + } + } + } +} + +pub fn is_youtube_video_id(value: &str) -> bool { + value.len() == 11 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +pub fn project_payload_from_content(content: &str) -> Result { + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + if let Some(version_value) = payload.get("projectFormatVersion") { + let version = version_value + .as_u64() + .ok_or_else(|| "Invalid project file format".to_string())?; + if version != u64::from(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); + } + + if let Some(sections) = payload.get("sections").and_then(Value::as_array) { + for (section_index, section) in sections.iter().enumerate() { + if section + .as_object() + .is_some_and(|section_object| !section_object.contains_key("timeRange")) + { + return Err(format!( + "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." + )); + } + } + } + + serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) +} + +/// Serialize one validated song into the current versioned project envelope. +pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> 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 { + pub score_id: String, + pub file_name: String, + pub file_size_bytes: u64, +} + +/// Security Notes: project ids never come from free-form user input. They are +/// only ever minted by `next_project_id` as `project--`, so +/// anything from the WebView that does not match that exact shape is rejected +/// before it can influence a filesystem path (no separators, no `..`). +pub fn is_valid_project_id(value: &str) -> bool { + let Some(rest) = value.strip_prefix("project-") else { + return false; + }; + let mut segments = rest.split('-'); + match (segments.next(), segments.next(), segments.next()) { + (Some(timestamp), Some(counter), None) => { + !timestamp.is_empty() + && !counter.is_empty() + && timestamp.bytes().all(|byte| byte.is_ascii_digit()) + && counter.bytes().all(|byte| byte.is_ascii_digit()) + } + _ => false, + } +} + +/// Security Notes: score ids are minted locally via UUID v4 and must round-trip +/// as exactly a lowercase hyphenated UUID (8-4-4-4-12). This is an allowlist +/// check, so path traversal payloads (`..`, separators, null bytes) can never +/// reach the path join below. +pub fn is_valid_score_id(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }) +} + +/// Security Notes: the selected file is untrusted input (`User Input Boundary`). +/// We refuse symlinks before canonicalizing, require a real non-empty regular +/// file with a `.pdf` extension, cap the size at 25MB, and verify the `%PDF-` +/// magic bytes so a mislabeled file cannot be attached as a score. +pub fn validate_score_pdf_source(path: &Path) -> Result<(PathBuf, String, u64), String> { + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("Could not read the selected PDF file.".to_string()); + } + + #[cfg(coverage)] + let canonical = path + .canonicalize() + .expect("score PDF path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = path + .canonicalize() + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; + if extension != "pdf" { + return Err("Choose a PDF file to attach as a score.".into()); + } + + let metadata = link_metadata; + if !metadata.is_file() || metadata.len() == 0 { + return Err("Could not read the selected PDF file.".into()); + } + if metadata.len() > MAX_SCORE_PDF_BYTES { + return Err("Score PDF is too large (exceeds 25MB limit).".into()); + } + + let mut header = [0u8; PDF_MAGIC.len()]; + std::fs::File::open(&canonical) + .and_then(|mut file| file.read_exact(&mut header)) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + if header != PDF_MAGIC { + return Err("The selected file is not a valid PDF.".into()); + } + + #[cfg(coverage)] + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .expect("canonical score PDF path should have a file name") + .to_string(); + #[cfg(not(coverage))] + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .ok_or_else(|| "Could not read the selected PDF file.".to_string())?; + + let file_size_bytes = metadata.len(); + Ok((canonical, file_name, file_size_bytes)) +} + +/// Security Notes: reads and deletes never accept an arbitrary path from the +/// WebView. The path is rebuilt server-side from validated ids, symlinks are +/// refused, and the canonicalized result must still live under the +/// canonicalized app-owned scores root (path-traversal guard). +pub fn resolve_existing_score_pdf(scores_root: &Path, score_id: &str) -> Result { + if !is_valid_score_id(score_id) { + return Err("Score was not found.".to_string()); + } + let candidate = scores_root.join(format!("{score_id}.pdf")); + let link_metadata = + std::fs::symlink_metadata(&candidate).map_err(|_| "Score was not found.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("Score was not found.".to_string()); + } + + #[cfg(coverage)] + let canonical = candidate + .canonicalize() + .expect("stored score path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = candidate + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + #[cfg(not(coverage))] + { + let canonical_root = scores_root + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + if !canonical.starts_with(&canonical_root) { + return Err("Score was not found.".to_string()); + } + } + + let metadata = link_metadata; + if !metadata.is_file() { + return Err("Score was not found.".to_string()); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) + } + + fn shared_contract_payload(time_range: Value) -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": time_range, + "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" + }, + "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.", + "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": ["lead-vocal"], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + } + }) + } + + #[test] + fn rehearsal_song_payload_accepts_shared_section_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("shared rehearsal song contract should deserialize in Tauri"); + + assert_eq!(parsed.sections[0].id, "verse-1"); + } + + #[test] + fn rehearsal_song_payload_round_trips_score_attachments() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", "fileName": "opener.pdf" } + ]); + + let parsed = serde_json::from_value::(payload) + .expect("song payload with score attachments should deserialize"); + let attachments = parsed + .score_attachments + .as_ref() + .expect("score attachments should survive deserialization"); + assert_eq!(attachments[0].file_name, "opener.pdf"); + + let serialized = + serde_json::to_value(&parsed).expect("song payload should serialize back to JSON"); + assert_eq!( + serialized["scoreAttachments"][0]["fileName"], + json!("opener.pdf") + ); + } + + #[test] + fn rehearsal_song_payload_accepts_legacy_files_without_score_attachments() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("legacy payload without score attachments should deserialize"); + + assert!(parsed.score_attachments.is_none()); + let serialized = + serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); + assert!(serialized.get("scoreAttachments").is_none()); + } + + #[test] + fn rehearsal_song_payload_rejects_unknown_score_attachment_fields() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { + "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", + "fileName": "opener.pdf", + "sourcePath": "/etc/passwd" + } + ]); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn rehearsal_song_payload_rejects_reversed_time_range() { + let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn project_payload_from_content_rejects_legacy_missing_time_range() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["sections"][0] + .as_object_mut() + .expect("section should be an object") + .remove("timeRange"); + let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); + + let error = project_payload_from_content(&content) + .expect_err("legacy sections without timing should fail closed"); + + assert!(error.contains("timeRange")); + } + + #[test] + fn project_payload_from_content_accepts_current_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let content = serde_json::to_string(&payload).expect("payload should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("current shared contract should parse directly"); + + 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!( + project_payload_from_content("{").expect_err("malformed JSON should fail"), + "Invalid project file format" + ); + + let error = project_payload_from_content(r#"{"sections":[]}"#) + .expect_err("incomplete payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = project_payload_from_content(r#"{"sections":[null]}"#) + .expect_err("malformed section entries should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = project_payload_from_content(r#"{"title":"Late Night Set"}"#) + .expect_err("sectionless payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = + project_payload_from_content(r#"{"sections":[{"timeRange":{"start":0,"end":1}}]}"#) + .expect_err("timed but incomplete payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + } + + #[test] + fn youtube_url_validation_requires_exact_video_ids() { + assert!(is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url( + "https://www.youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); + + assert!(!is_supported_youtube_url( + "https://evil.youtube.com/watch?v=abc123DEF45" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF4!" + )); + assert!(!is_supported_youtube_url("https://youtube.com/watch")); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" + )); + assert!(!is_supported_youtube_url("https://youtu.be/abc123")); + assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); + } + + #[test] + fn youtube_url_validation_rejects_malformed_and_nonstandard_urls() { + assert!(!is_supported_youtube_url("not a url")); + assert!(!is_supported_youtube_url( + "http://youtube.com/watch?v=abc123DEF45" + )); + assert!(!is_supported_youtube_url("https://youtu.be/")); + assert!(!is_supported_youtube_url( + "https://youtube.com/embed/abc123DEF45" + )); + + let long_url = format!("https://youtube.com/watch?v={}", "a".repeat(2000)); + assert!(!is_supported_youtube_url(&long_url)); + } + + #[test] + fn youtube_missing_metadata_error_does_not_expose_payload() { + let parsed = json!({ + "ok": true, + "filepath": "/Users/someone/private-song.m4a", + "metadata": null + }); + + let message = youtube_missing_metadata_error(&parsed); + + assert_eq!(message, "YouTube import reported ok but missing metadata."); + assert!(!message.contains("private-song")); + assert!(!message.contains("filepath")); + } + + #[test] + fn youtube_process_timeout_kills_and_reaps_child() { + let command = long_sleep_command(); + + let result = wait_for_process_output( + command, + Duration::from_millis(50), + Duration::from_millis(5), + "YouTube import timed out.", + ); + + assert_eq!( + result.expect_err("slow child should time out"), + "YouTube import timed out." + ); + } + + #[test] + fn youtube_process_output_reports_spawn_failure() { + let command = Command::new(unique_test_dir("missing-youtube-command").join("missing-tool")); + + let result = wait_for_process_output( + command, + Duration::from_millis(50), + Duration::from_millis(5), + "YouTube import timed out.", + ); + + assert_eq!( + result.expect_err("missing helper should fail at spawn"), + "Failed to start YouTube import process." + ); + } + + fn long_sleep_command() -> Command { + #[cfg(windows)] + { + let mut command = Command::new("powershell"); + command + .arg("-NoProfile") + .arg("-Command") + .arg("Start-Sleep -Seconds 5"); + command + } + + #[cfg(not(windows))] + { + let mut command = Command::new("sh"); + command.arg("-c").arg("sleep 5"); + command + } + } + + #[test] + fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { + if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { + let chunk = vec![b'x'; 1024 * 1024]; + std::io::stdout() + .write_all(&chunk) + .expect("child stdout should accept test bytes"); + std::io::stderr() + .write_all(&chunk) + .expect("child stderr should accept test bytes"); + return; + } + + let current_test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut command = Command::new(current_test_binary); + command + .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") + .arg("--exact") + .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") + .arg("--nocapture"); + + let output = wait_for_process_output( + command, + Duration::from_secs(2), + Duration::from_millis(5), + "YouTube import timed out.", + ) + .expect("large child output should be drained before timeout"); + + assert!(output.status.success()); + assert!(output.stdout.len() >= 1024 * 1024); + assert!(output.stderr.len() >= 1024 * 1024); + } + + #[test] + fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { + let cache_root = unique_test_dir("youtube-cache"); + let outside_root = unique_test_dir("youtube-outside"); + std::fs::create_dir_all(&cache_root).expect("cache root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let inside_file = cache_root.join("downloaded.m4a"); + let empty_file = cache_root.join("empty.m4a"); + let unsupported_file = cache_root.join("downloaded.txt"); + let no_extension_file = cache_root.join("downloaded"); + let outside_file = outside_root.join("downloaded.m4a"); + std::fs::write(&inside_file, b"audio").expect("inside file should be written"); + std::fs::write(&empty_file, b"").expect("empty file should be written"); + std::fs::write(&unsupported_file, b"not audio") + .expect("unsupported file should be written"); + std::fs::write(&no_extension_file, b"audio").expect("extensionless file should be written"); + std::fs::write(&outside_file, b"audio").expect("outside file should be written"); + + let accepted = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live/Test" }), + &cache_root, + ) + .expect("in-cache supported audio should be accepted"); + assert_eq!(accepted.extension, "m4a"); + assert_eq!(accepted.file_name, "Live_Test.m4a"); + + let default_title = + youtube_source_from_metadata(&json!({ "filepath": inside_file }), &cache_root) + .expect("missing YouTube title should use the default filename stem"); + assert_eq!(default_title.file_name, "Unknown YouTube Audio.m4a"); + + let empty_title = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "" }), + &cache_root, + ) + .expect("empty YouTube title should use the safe fallback filename stem"); + assert_eq!(empty_title.file_name, "youtube_audio.m4a"); + + let control_title = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live\u{0007}Bell" }), + &cache_root, + ) + .expect("control characters should be sanitized out of filenames"); + assert_eq!(control_title.file_name, "Live_Bell.m4a"); + + assert_eq!( + youtube_source_from_metadata(&json!({ "title": "Live" }), &cache_root) + .expect_err("missing filepath should fail closed"), + "Failed to parse YouTube import response." + ); + assert_eq!( + youtube_source_from_metadata( + &json!({ "filepath": cache_root.join("missing.m4a"), "title": "Live" }), + &cache_root, + ) + .expect_err("missing downloaded file should fail closed"), + "Could not read downloaded audio file." + ); + let missing_cache_root = unique_test_dir("youtube-missing-cache"); + assert_eq!( + youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live" }), + &missing_cache_root, + ) + .expect_err("missing cache root should fail closed"), + "Could not validate YouTube import workspace." + ); + assert!(youtube_source_from_metadata( + &json!({ "filepath": empty_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": unsupported_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": no_extension_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": outside_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + + #[cfg(unix)] + { + let symlink_file = cache_root.join("linked.m4a"); + std::os::unix::fs::symlink(&inside_file, &symlink_file) + .expect("symlink should be created"); + assert!(youtube_source_from_metadata( + &json!({ "filepath": symlink_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + } + + let _ = std::fs::remove_dir_all(cache_root); + let _ = std::fs::remove_dir_all(outside_root); + } + + #[test] + fn project_id_guard_accepts_generated_ids_only() { + let generated = next_project_id(&AppState::default()); + assert!(is_valid_project_id(&generated)); + assert!(is_valid_project_id("project-1751234567890123456-1")); + + assert!(!is_valid_project_id("")); + assert!(!is_valid_project_id("project-")); + assert!(!is_valid_project_id("project-123")); + assert!(!is_valid_project_id("project-123-")); + assert!(!is_valid_project_id("project-123-4-5")); + assert!(!is_valid_project_id("project-abc-1")); + assert!(!is_valid_project_id("project-123-1x")); + assert!(!is_valid_project_id("other-123-1")); + assert!(!is_valid_project_id("../project-123-1")); + assert!(!is_valid_project_id("project-123-1/..")); + assert!(!is_valid_project_id("project-..-1")); + assert!(!is_valid_project_id("project-123-1/escape")); + } + + #[test] + fn score_id_guard_accepts_lowercase_uuid_v4_only() { + let generated = uuid::Uuid::new_v4().to_string(); + assert!(is_valid_score_id(&generated)); + assert!(is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e160355e")); + + assert!(!is_valid_score_id("")); + assert!(!is_valid_score_id("not-a-uuid")); + assert!(!is_valid_score_id("6FA459EA-EE8A-3CA4-894E-DB77E160355E")); + assert!(!is_valid_score_id("6fa459eaee8a3ca4894edb77e160355e")); + assert!(!is_valid_score_id("{6fa459ea-ee8a-3ca4-894e-db77e160355e}")); + assert!(!is_valid_score_id("../../../../etc/passwd-aaaa-bbbb-cc")); + assert!(!is_valid_score_id( + "6fa459ea-ee8a-3ca4-894e-db77e160355e/.." + )); + assert!(!is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e16035/e")); + } + + #[test] + fn score_pdf_source_requires_pdf_magic_size_and_real_file() { + let root = unique_test_dir("score-source"); + std::fs::create_dir_all(&root).expect("score source root should be created"); + + let valid = root.join("score.pdf"); + std::fs::write(&valid, b"%PDF-1.7 fake body").expect("valid pdf should be written"); + let (canonical, file_name, size) = + validate_score_pdf_source(&valid).expect("valid pdf should be accepted"); + assert_eq!(file_name, "score.pdf"); + assert_eq!(size, 18); + assert!(canonical.ends_with("score.pdf")); + + let wrong_magic = root.join("not-really.pdf"); + std::fs::write(&wrong_magic, b"PK\x03\x04 zip bytes") + .expect("wrong magic file should be written"); + assert!(validate_score_pdf_source(&wrong_magic).is_err()); + + let short = root.join("short.pdf"); + std::fs::write(&short, b"%PD").expect("short file should be written"); + assert!(validate_score_pdf_source(&short).is_err()); + + let empty = root.join("empty.pdf"); + std::fs::write(&empty, b"").expect("empty file should be written"); + assert!(validate_score_pdf_source(&empty).is_err()); + + let wrong_extension = root.join("score.txt"); + std::fs::write(&wrong_extension, b"%PDF-1.7").expect("txt file should be written"); + assert!(validate_score_pdf_source(&wrong_extension).is_err()); + + let missing_extension = root.join("score"); + std::fs::write(&missing_extension, b"%PDF-1.7") + .expect("extensionless score file should be written"); + assert!(validate_score_pdf_source(&missing_extension).is_err()); + + let missing = root.join("missing.pdf"); + assert!(validate_score_pdf_source(&missing).is_err()); + + let oversized = root.join("oversized.pdf"); + { + let file = std::fs::File::create(&oversized).expect("oversized file should be created"); + let mut file = file; + file.write_all(b"%PDF-1.7") + .expect("oversized header should be written"); + file.set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("oversized file should be extended"); + } + assert!(validate_score_pdf_source(&oversized).is_err()); + + #[cfg(unix)] + { + let symlinked = root.join("linked.pdf"); + std::os::unix::fs::symlink(&valid, &symlinked).expect("symlink should be created"); + assert!(validate_score_pdf_source(&symlinked).is_err()); + } + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn score_pdf_resolution_rejects_traversal_and_escapes() { + let scores_root = unique_test_dir("score-resolve"); + let outside_root = unique_test_dir("score-outside"); + std::fs::create_dir_all(&scores_root).expect("scores root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + let inside_file = scores_root.join(format!("{score_id}.pdf")); + std::fs::write(&inside_file, b"%PDF-1.7").expect("inside file should be written"); + + let resolved = resolve_existing_score_pdf(&scores_root, score_id) + .expect("stored score inside the root should resolve"); + assert!(resolved.ends_with(format!("{score_id}.pdf"))); + + let directory_id = "22222222-3333-4444-5555-666666666666"; + std::fs::create_dir(scores_root.join(format!("{directory_id}.pdf"))) + .expect("directory named like a score should be created"); + assert!(resolve_existing_score_pdf(&scores_root, directory_id).is_err()); + + assert!(resolve_existing_score_pdf(&scores_root, "../escape").is_err()); + assert!(resolve_existing_score_pdf(&scores_root, "..").is_err()); + assert!( + resolve_existing_score_pdf(&scores_root, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + .is_err() + ); + + #[cfg(unix)] + { + let outside_file = outside_root.join("secret.pdf"); + std::fs::write(&outside_file, b"%PDF-1.7").expect("outside file should be written"); + let linked_id = "11111111-2222-3333-4444-555555555555"; + std::os::unix::fs::symlink(&outside_file, scores_root.join(format!("{linked_id}.pdf"))) + .expect("symlink should be created"); + assert!(resolve_existing_score_pdf(&scores_root, linked_id).is_err()); + } + + let _ = std::fs::remove_dir_all(scores_root); + let _ = std::fs::remove_dir_all(outside_root); + } +} diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index aaf2fc812..671e1a555 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -1,1663 +1,15 @@ -//! Pure, GUI-independent logic for the BandScope desktop app. +//! Public crate root for GUI-independent BandScope desktop logic. //! -//! This crate holds every payload contract, validation guard, and process -//! helper that does not depend on Tauri or the WebView runtime. Keeping it -//! free of `tauri`/`wry` lets the full unit-test suite build and run (and be -//! measured for coverage) on any platform without a windowing system or a -//! bundled frontend. - -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use std::{ - collections::HashMap, - io::Read, - path::{Path, PathBuf}, - process::{Command, Stdio}, - sync::{ - atomic::{AtomicU64, AtomicUsize, Ordering}, - Arc, Mutex, - }, - thread, - time::{Duration, Instant}, +//! The historical payload/process surface remains in `core`; Project +//! Persistence format evolution is isolated in `project_format` so durable +//! migration rules do not become another renderer or Tauri storage authority. + +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_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, + SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, }; -use time::OffsetDateTime; - -#[derive(Clone)] -pub struct AppState(pub Arc); - -pub struct AppStateInner { - pub next_job: AtomicU64, - pub in_flight_jobs: AtomicUsize, - pub jobs: Mutex>, - pub bootstrap_sources: Mutex>, -} - -pub const MAX_IN_FLIGHT_JOBS: usize = 2; - -pub const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); - -pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); - -pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; - -pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; - -pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); - -pub const MAX_YOUTUBE_URL_LENGTH: usize = 2000; - -pub const MAX_SCORE_PDF_BYTES: u64 = 25 * 1024 * 1024; - -pub const PDF_MAGIC: &[u8] = b"%PDF-"; - -impl Default for AppState { - fn default() -> Self { - Self(Arc::new(AppStateInner { - next_job: AtomicU64::new(1), - in_flight_jobs: AtomicUsize::new(0), - jobs: Mutex::new(HashMap::new()), - bootstrap_sources: Mutex::new(HashMap::new()), - })) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobRequest { - pub source_kind: String, - pub project_id: Option, - pub source_label: String, - pub role_focus: Vec, - pub local_source: Option, - pub cache_root: Option, - pub temp_root: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobErrorCode { - InvalidRequest, - NotFound, - EngineUnavailable, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobError { - pub code: AnalysisJobErrorCode, - pub message: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobState { - Queued, - Running, - Succeeded, - Failed, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobStage { - Queued, - Decode, - Separate, - Analyze, - Persist, - Ready, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisCacheStatus { - Disabled, - Miss, - Hit, - Stored, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -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, - 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. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ScoreAttachmentMetadataPayload { - id: String, - 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: 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: CueKindPayload, - value: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RangePayload { - lowest_note: String, - highest_note: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HarmonyPayload { - chord: String, - function_label: 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: 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)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RehearsalRolePayload { - id: String, - name: 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: 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)] -#[serde(rename_all = "camelCase")] -pub struct SectionTimeRangePayload { - start: u32, - end: u32, -} - -impl<'de> Deserialize<'de> for SectionTimeRangePayload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct RawSectionTimeRangePayload { - start: u32, - end: u32, - } - - let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; - if raw.end <= raw.start { - return Err(serde::de::Error::custom( - "section timeRange end must be greater than start", - )); - } - - Ok(Self { - start: raw.start, - end: raw.end, - }) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct PartGraphNodePayload { - role_id: String, - is_active: bool, - handoff_to: Vec, - 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: SectionFormLabelPayload, - groove: String, - time_range: SectionTimeRangePayload, - confidence: ConfidencePayload, - roles: Vec, - 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: ExportFormatPayload, - headline: String, - focus_sections: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobStatus { - pub job_id: String, - pub state: AnalysisJobState, - pub requested_at: String, - pub updated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_percent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LocalAudioSourcePayload { - pub source_path: String, - pub file_name: String, - pub extension: String, - pub file_size_bytes: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ProjectBootstrapSummaryPayload { - pub project_id: String, - pub source_mode: String, - pub project_root: String, - pub cache_root: String, - pub temp_root: String, - pub source: LocalAudioSourcePayload, -} - -pub fn next_project_id(state: &AppState) -> String { - format!( - "project-{}-{}", - OffsetDateTime::now_utc().unix_timestamp_nanos(), - state.0.next_job.fetch_add(1, Ordering::Relaxed) - ) -} - -pub fn youtube_source_from_metadata( - metadata: &Value, - cache_root: &Path, -) -> Result { - let filepath = metadata - .get("filepath") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; - let title = metadata - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or("Unknown YouTube Audio"); - let path = Path::new(filepath); - let link_metadata = std::fs::symlink_metadata(path) - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let canonical_cache_root = cache_root - .canonicalize() - .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; - #[cfg(coverage)] - let canonical = path - .canonicalize() - .expect("downloaded audio path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = path - .canonicalize() - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - if !canonical.starts_with(&canonical_cache_root) { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let file_metadata = link_metadata; - if !file_metadata.is_file() || file_metadata.len() == 0 { - return Err("YouTube import returned an invalid audio file.".to_string()); - } - - let extension = canonical - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; - if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { - return Err("YouTube import returned an unsupported audio format.".to_string()); - } - - let safe_title: String = title - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', - c if c.is_control() => '_', - c => c, - }) - .take(100) - .collect(); - let safe_title = if safe_title.is_empty() { - "youtube_audio".to_string() - } else { - safe_title - }; - - Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: format!("{safe_title}.{extension}"), - extension, - file_size_bytes: file_metadata.len(), - }) -} - -pub fn is_supported_youtube_url(url: &str) -> bool { - if url.len() > MAX_YOUTUBE_URL_LENGTH { - return false; - } - - let parsed_url = match url::Url::parse(url) { - Ok(u) => u, - Err(_) => return false, - }; - if parsed_url.scheme() != "https" { - return false; - } - - let host = parsed_url.host_str().unwrap_or("").to_lowercase(); - if host == "youtu.be" { - let mut segments = parsed_url - .path_segments() - .expect("https URLs should expose path segments") - .filter(|segment| !segment.is_empty()); - let Some(video_id) = segments.next() else { - return false; - }; - return is_youtube_video_id(video_id) && segments.next().is_none(); - } - - if host == "youtube.com" || host == "www.youtube.com" { - if parsed_url.path() != "/watch" { - return false; - } - let mut video_ids = parsed_url - .query_pairs() - .filter(|(key, _)| key == "v") - .map(|(_, value)| value); - return match (video_ids.next(), video_ids.next()) { - (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), - _ => false, - }; - } - - false -} - -pub fn youtube_missing_metadata_error(_parsed: &Value) -> String { - "YouTube import reported ok but missing metadata.".to_string() -} - -pub fn wait_for_process_output( - mut command: Command, - timeout: Duration, - poll_interval: Duration, - timeout_message: &str, -) -> Result { - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|_| "Failed to start YouTube import process.".to_string())?; - let stdout = child - .stdout - .take() - .expect("stdout should be piped for YouTube import process"); - let stderr = child - .stderr - .take() - .expect("stderr should be piped for YouTube import process"); - let stdout_reader = thread::spawn(move || { - let mut reader = stdout; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let stderr_reader = thread::spawn(move || { - let mut reader = stderr; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let deadline = Instant::now() + timeout; - - loop { - let process_status = { - #[cfg(coverage)] - { - child - .try_wait() - .expect("YouTube process status polling should not fail under coverage") - } - #[cfg(not(coverage))] - { - match child.try_wait() { - Ok(status) => status, - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err("Failed to execute YouTube import process.".to_string()); - } - } - } - }; - - match process_status { - Some(status) => { - #[cfg(coverage)] - let stdout = stdout_reader - .join() - .expect("stdout reader should not panic") - .expect("stdout reader should read process output"); - #[cfg(not(coverage))] - let stdout = stdout_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - #[cfg(coverage)] - let stderr = stderr_reader - .join() - .expect("stderr reader should not panic") - .expect("stderr reader should read process output"); - #[cfg(not(coverage))] - let stderr = stderr_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - return Ok(std::process::Output { - status, - stdout, - stderr, - }); - } - None => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(timeout_message.to_string()); - } - thread::sleep(poll_interval); - } - } - } -} - -pub fn is_youtube_video_id(value: &str) -> bool { - value.len() == 11 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') -} - -pub fn project_payload_from_content(content: &str) -> Result { - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; - - if let Some(version_value) = payload.get("projectFormatVersion") { - let version = version_value - .as_u64() - .ok_or_else(|| "Invalid project file format".to_string())?; - if version != u64::from(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); - } - - if let Some(sections) = payload.get("sections").and_then(Value::as_array) { - for (section_index, section) in sections.iter().enumerate() { - if section - .as_object() - .is_some_and(|section_object| !section_object.contains_key("timeRange")) - { - return Err(format!( - "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." - )); - } - } - } - - serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) -} - -/// Serialize one validated song into the current versioned project envelope. -pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> 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 { - pub score_id: String, - pub file_name: String, - pub file_size_bytes: u64, -} - -/// Security Notes: project ids never come from free-form user input. They are -/// only ever minted by `next_project_id` as `project--`, so -/// anything from the WebView that does not match that exact shape is rejected -/// before it can influence a filesystem path (no separators, no `..`). -pub fn is_valid_project_id(value: &str) -> bool { - let Some(rest) = value.strip_prefix("project-") else { - return false; - }; - let mut segments = rest.split('-'); - match (segments.next(), segments.next(), segments.next()) { - (Some(timestamp), Some(counter), None) => { - !timestamp.is_empty() - && !counter.is_empty() - && timestamp.bytes().all(|byte| byte.is_ascii_digit()) - && counter.bytes().all(|byte| byte.is_ascii_digit()) - } - _ => false, - } -} - -/// Security Notes: score ids are minted locally via UUID v4 and must round-trip -/// as exactly a lowercase hyphenated UUID (8-4-4-4-12). This is an allowlist -/// check, so path traversal payloads (`..`, separators, null bytes) can never -/// reach the path join below. -pub fn is_valid_score_id(value: &str) -> bool { - let bytes = value.as_bytes(); - if bytes.len() != 36 { - return false; - } - bytes.iter().enumerate().all(|(index, byte)| match index { - 8 | 13 | 18 | 23 => *byte == b'-', - _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), - }) -} - -/// Security Notes: the selected file is untrusted input (`User Input Boundary`). -/// We refuse symlinks before canonicalizing, require a real non-empty regular -/// file with a `.pdf` extension, cap the size at 25MB, and verify the `%PDF-` -/// magic bytes so a mislabeled file cannot be attached as a score. -pub fn validate_score_pdf_source(path: &Path) -> Result<(PathBuf, String, u64), String> { - let link_metadata = std::fs::symlink_metadata(path) - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("Could not read the selected PDF file.".to_string()); - } - - #[cfg(coverage)] - let canonical = path - .canonicalize() - .expect("score PDF path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = path - .canonicalize() - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - let extension = canonical - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; - if extension != "pdf" { - return Err("Choose a PDF file to attach as a score.".into()); - } - - let metadata = link_metadata; - if !metadata.is_file() || metadata.len() == 0 { - return Err("Could not read the selected PDF file.".into()); - } - if metadata.len() > MAX_SCORE_PDF_BYTES { - return Err("Score PDF is too large (exceeds 25MB limit).".into()); - } - - let mut header = [0u8; PDF_MAGIC.len()]; - std::fs::File::open(&canonical) - .and_then(|mut file| file.read_exact(&mut header)) - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - if header != PDF_MAGIC { - return Err("The selected file is not a valid PDF.".into()); - } - - #[cfg(coverage)] - let file_name = canonical - .file_name() - .and_then(|value| value.to_str()) - .expect("canonical score PDF path should have a file name") - .to_string(); - #[cfg(not(coverage))] - let file_name = canonical - .file_name() - .and_then(|value| value.to_str()) - .map(|value| value.to_string()) - .ok_or_else(|| "Could not read the selected PDF file.".to_string())?; - - let file_size_bytes = metadata.len(); - Ok((canonical, file_name, file_size_bytes)) -} - -/// Security Notes: reads and deletes never accept an arbitrary path from the -/// WebView. The path is rebuilt server-side from validated ids, symlinks are -/// refused, and the canonicalized result must still live under the -/// canonicalized app-owned scores root (path-traversal guard). -pub fn resolve_existing_score_pdf(scores_root: &Path, score_id: &str) -> Result { - if !is_valid_score_id(score_id) { - return Err("Score was not found.".to_string()); - } - let candidate = scores_root.join(format!("{score_id}.pdf")); - let link_metadata = - std::fs::symlink_metadata(&candidate).map_err(|_| "Score was not found.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("Score was not found.".to_string()); - } - - #[cfg(coverage)] - let canonical = candidate - .canonicalize() - .expect("stored score path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = candidate - .canonicalize() - .map_err(|_| "Score was not found.".to_string())?; - #[cfg(not(coverage))] - { - let canonical_root = scores_root - .canonicalize() - .map_err(|_| "Score was not found.".to_string())?; - if !canonical.starts_with(&canonical_root) { - return Err("Score was not found.".to_string()); - } - } - - let metadata = link_metadata; - if !metadata.is_file() { - return Err("Score was not found.".to_string()); - } - Ok(canonical) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::io::Write; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_test_dir(name: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) - } - - fn shared_contract_payload(time_range: Value) -> Value { - json!({ - "id": "demo-song", - "title": "Late Night Set", - "sections": [ - { - "id": "verse-1", - "label": "verse", - "groove": "Straight eighths with a late snare feel", - "timeRange": time_range, - "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" - }, - "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.", - "manualOverrides": [], - "overlapWarnings": [ - "Density warning: competing with Keyboard Left Hand in low register." - ] - } - ], - "partGraph": [ - { - "role_id": "bass-guitar", - "is_active": true, - "handoff_to": ["lead-vocal"], - "handoff_from": [] - } - ] - } - ], - "exportSummary": { - "format": "cue-sheet", - "headline": "Start with the verse handoff and low-register overlap.", - "focusSections": ["verse-1"] - } - }) - } - - #[test] - fn rehearsal_song_payload_accepts_shared_section_contract() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - - let parsed = serde_json::from_value::(payload) - .expect("shared rehearsal song contract should deserialize in Tauri"); - - assert_eq!(parsed.sections[0].id, "verse-1"); - } - - #[test] - fn rehearsal_song_payload_round_trips_score_attachments() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["scoreAttachments"] = json!([ - { "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", "fileName": "opener.pdf" } - ]); - - let parsed = serde_json::from_value::(payload) - .expect("song payload with score attachments should deserialize"); - let attachments = parsed - .score_attachments - .as_ref() - .expect("score attachments should survive deserialization"); - assert_eq!(attachments[0].file_name, "opener.pdf"); - - let serialized = - serde_json::to_value(&parsed).expect("song payload should serialize back to JSON"); - assert_eq!( - serialized["scoreAttachments"][0]["fileName"], - json!("opener.pdf") - ); - } - - #[test] - fn rehearsal_song_payload_accepts_legacy_files_without_score_attachments() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - - let parsed = serde_json::from_value::(payload) - .expect("legacy payload without score attachments should deserialize"); - - assert!(parsed.score_attachments.is_none()); - let serialized = - serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); - assert!(serialized.get("scoreAttachments").is_none()); - } - - #[test] - fn rehearsal_song_payload_rejects_unknown_score_attachment_fields() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["scoreAttachments"] = json!([ - { - "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", - "fileName": "opener.pdf", - "sourcePath": "/etc/passwd" - } - ]); - - assert!(serde_json::from_value::(payload).is_err()); - } - - #[test] - fn rehearsal_song_payload_rejects_reversed_time_range() { - let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); - - assert!(serde_json::from_value::(payload).is_err()); - } - - #[test] - fn project_payload_from_content_rejects_legacy_missing_time_range() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["sections"][0] - .as_object_mut() - .expect("section should be an object") - .remove("timeRange"); - let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); - - let error = project_payload_from_content(&content) - .expect_err("legacy sections without timing should fail closed"); - - assert!(error.contains("timeRange")); - } - - #[test] - fn project_payload_from_content_accepts_current_contract() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - let content = serde_json::to_string(&payload).expect("payload should serialize"); - - let parsed = project_payload_from_content(&content) - .expect("current shared contract should parse directly"); - - 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!( - project_payload_from_content("{").expect_err("malformed JSON should fail"), - "Invalid project file format" - ); - - let error = project_payload_from_content(r#"{"sections":[]}"#) - .expect_err("incomplete payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = project_payload_from_content(r#"{"sections":[null]}"#) - .expect_err("malformed section entries should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = project_payload_from_content(r#"{"title":"Late Night Set"}"#) - .expect_err("sectionless payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = - project_payload_from_content(r#"{"sections":[{"timeRange":{"start":0,"end":1}}]}"#) - .expect_err("timed but incomplete payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - } - - #[test] - fn youtube_url_validation_requires_exact_video_ids() { - assert!(is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url( - "https://www.youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); - - assert!(!is_supported_youtube_url( - "https://evil.youtube.com/watch?v=abc123DEF45" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF4!" - )); - assert!(!is_supported_youtube_url("https://youtube.com/watch")); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" - )); - assert!(!is_supported_youtube_url("https://youtu.be/abc123")); - assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); - } - - #[test] - fn youtube_url_validation_rejects_malformed_and_nonstandard_urls() { - assert!(!is_supported_youtube_url("not a url")); - assert!(!is_supported_youtube_url( - "http://youtube.com/watch?v=abc123DEF45" - )); - assert!(!is_supported_youtube_url("https://youtu.be/")); - assert!(!is_supported_youtube_url( - "https://youtube.com/embed/abc123DEF45" - )); - - let long_url = format!("https://youtube.com/watch?v={}", "a".repeat(2000)); - assert!(!is_supported_youtube_url(&long_url)); - } - - #[test] - fn youtube_missing_metadata_error_does_not_expose_payload() { - let parsed = json!({ - "ok": true, - "filepath": "/Users/someone/private-song.m4a", - "metadata": null - }); - - let message = youtube_missing_metadata_error(&parsed); - - assert_eq!(message, "YouTube import reported ok but missing metadata."); - assert!(!message.contains("private-song")); - assert!(!message.contains("filepath")); - } - - #[test] - fn youtube_process_timeout_kills_and_reaps_child() { - let command = long_sleep_command(); - - let result = wait_for_process_output( - command, - Duration::from_millis(50), - Duration::from_millis(5), - "YouTube import timed out.", - ); - - assert_eq!( - result.expect_err("slow child should time out"), - "YouTube import timed out." - ); - } - - #[test] - fn youtube_process_output_reports_spawn_failure() { - let command = Command::new(unique_test_dir("missing-youtube-command").join("missing-tool")); - - let result = wait_for_process_output( - command, - Duration::from_millis(50), - Duration::from_millis(5), - "YouTube import timed out.", - ); - - assert_eq!( - result.expect_err("missing helper should fail at spawn"), - "Failed to start YouTube import process." - ); - } - - fn long_sleep_command() -> Command { - #[cfg(windows)] - { - let mut command = Command::new("powershell"); - command - .arg("-NoProfile") - .arg("-Command") - .arg("Start-Sleep -Seconds 5"); - command - } - - #[cfg(not(windows))] - { - let mut command = Command::new("sh"); - command.arg("-c").arg("sleep 5"); - command - } - } - - #[test] - fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { - if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { - let chunk = vec![b'x'; 1024 * 1024]; - std::io::stdout() - .write_all(&chunk) - .expect("child stdout should accept test bytes"); - std::io::stderr() - .write_all(&chunk) - .expect("child stderr should accept test bytes"); - return; - } - - let current_test_binary = std::env::current_exe().expect("test binary should resolve"); - let mut command = Command::new(current_test_binary); - command - .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") - .arg("--exact") - .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") - .arg("--nocapture"); - - let output = wait_for_process_output( - command, - Duration::from_secs(2), - Duration::from_millis(5), - "YouTube import timed out.", - ) - .expect("large child output should be drained before timeout"); - - assert!(output.status.success()); - assert!(output.stdout.len() >= 1024 * 1024); - assert!(output.stderr.len() >= 1024 * 1024); - } - - #[test] - fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { - let cache_root = unique_test_dir("youtube-cache"); - let outside_root = unique_test_dir("youtube-outside"); - std::fs::create_dir_all(&cache_root).expect("cache root should be created"); - std::fs::create_dir_all(&outside_root).expect("outside root should be created"); - - let inside_file = cache_root.join("downloaded.m4a"); - let empty_file = cache_root.join("empty.m4a"); - let unsupported_file = cache_root.join("downloaded.txt"); - let no_extension_file = cache_root.join("downloaded"); - let outside_file = outside_root.join("downloaded.m4a"); - std::fs::write(&inside_file, b"audio").expect("inside file should be written"); - std::fs::write(&empty_file, b"").expect("empty file should be written"); - std::fs::write(&unsupported_file, b"not audio") - .expect("unsupported file should be written"); - std::fs::write(&no_extension_file, b"audio").expect("extensionless file should be written"); - std::fs::write(&outside_file, b"audio").expect("outside file should be written"); - - let accepted = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live/Test" }), - &cache_root, - ) - .expect("in-cache supported audio should be accepted"); - assert_eq!(accepted.extension, "m4a"); - assert_eq!(accepted.file_name, "Live_Test.m4a"); - - let default_title = - youtube_source_from_metadata(&json!({ "filepath": inside_file }), &cache_root) - .expect("missing YouTube title should use the default filename stem"); - assert_eq!(default_title.file_name, "Unknown YouTube Audio.m4a"); - - let empty_title = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "" }), - &cache_root, - ) - .expect("empty YouTube title should use the safe fallback filename stem"); - assert_eq!(empty_title.file_name, "youtube_audio.m4a"); - - let control_title = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live\u{0007}Bell" }), - &cache_root, - ) - .expect("control characters should be sanitized out of filenames"); - assert_eq!(control_title.file_name, "Live_Bell.m4a"); - - assert_eq!( - youtube_source_from_metadata(&json!({ "title": "Live" }), &cache_root) - .expect_err("missing filepath should fail closed"), - "Failed to parse YouTube import response." - ); - assert_eq!( - youtube_source_from_metadata( - &json!({ "filepath": cache_root.join("missing.m4a"), "title": "Live" }), - &cache_root, - ) - .expect_err("missing downloaded file should fail closed"), - "Could not read downloaded audio file." - ); - let missing_cache_root = unique_test_dir("youtube-missing-cache"); - assert_eq!( - youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live" }), - &missing_cache_root, - ) - .expect_err("missing cache root should fail closed"), - "Could not validate YouTube import workspace." - ); - assert!(youtube_source_from_metadata( - &json!({ "filepath": empty_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": unsupported_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": no_extension_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": outside_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - - #[cfg(unix)] - { - let symlink_file = cache_root.join("linked.m4a"); - std::os::unix::fs::symlink(&inside_file, &symlink_file) - .expect("symlink should be created"); - assert!(youtube_source_from_metadata( - &json!({ "filepath": symlink_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - } - - let _ = std::fs::remove_dir_all(cache_root); - let _ = std::fs::remove_dir_all(outside_root); - } - - #[test] - fn project_id_guard_accepts_generated_ids_only() { - let generated = next_project_id(&AppState::default()); - assert!(is_valid_project_id(&generated)); - assert!(is_valid_project_id("project-1751234567890123456-1")); - - assert!(!is_valid_project_id("")); - assert!(!is_valid_project_id("project-")); - assert!(!is_valid_project_id("project-123")); - assert!(!is_valid_project_id("project-123-")); - assert!(!is_valid_project_id("project-123-4-5")); - assert!(!is_valid_project_id("project-abc-1")); - assert!(!is_valid_project_id("project-123-1x")); - assert!(!is_valid_project_id("other-123-1")); - assert!(!is_valid_project_id("../project-123-1")); - assert!(!is_valid_project_id("project-123-1/..")); - assert!(!is_valid_project_id("project-..-1")); - assert!(!is_valid_project_id("project-123-1/escape")); - } - - #[test] - fn score_id_guard_accepts_lowercase_uuid_v4_only() { - let generated = uuid::Uuid::new_v4().to_string(); - assert!(is_valid_score_id(&generated)); - assert!(is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e160355e")); - - assert!(!is_valid_score_id("")); - assert!(!is_valid_score_id("not-a-uuid")); - assert!(!is_valid_score_id("6FA459EA-EE8A-3CA4-894E-DB77E160355E")); - assert!(!is_valid_score_id("6fa459eaee8a3ca4894edb77e160355e")); - assert!(!is_valid_score_id("{6fa459ea-ee8a-3ca4-894e-db77e160355e}")); - assert!(!is_valid_score_id("../../../../etc/passwd-aaaa-bbbb-cc")); - assert!(!is_valid_score_id( - "6fa459ea-ee8a-3ca4-894e-db77e160355e/.." - )); - assert!(!is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e16035/e")); - } - - #[test] - fn score_pdf_source_requires_pdf_magic_size_and_real_file() { - let root = unique_test_dir("score-source"); - std::fs::create_dir_all(&root).expect("score source root should be created"); - - let valid = root.join("score.pdf"); - std::fs::write(&valid, b"%PDF-1.7 fake body").expect("valid pdf should be written"); - let (canonical, file_name, size) = - validate_score_pdf_source(&valid).expect("valid pdf should be accepted"); - assert_eq!(file_name, "score.pdf"); - assert_eq!(size, 18); - assert!(canonical.ends_with("score.pdf")); - - let wrong_magic = root.join("not-really.pdf"); - std::fs::write(&wrong_magic, b"PK\x03\x04 zip bytes") - .expect("wrong magic file should be written"); - assert!(validate_score_pdf_source(&wrong_magic).is_err()); - - let short = root.join("short.pdf"); - std::fs::write(&short, b"%PD").expect("short file should be written"); - assert!(validate_score_pdf_source(&short).is_err()); - - let empty = root.join("empty.pdf"); - std::fs::write(&empty, b"").expect("empty file should be written"); - assert!(validate_score_pdf_source(&empty).is_err()); - - let wrong_extension = root.join("score.txt"); - std::fs::write(&wrong_extension, b"%PDF-1.7").expect("txt file should be written"); - assert!(validate_score_pdf_source(&wrong_extension).is_err()); - - let missing_extension = root.join("score"); - std::fs::write(&missing_extension, b"%PDF-1.7") - .expect("extensionless score file should be written"); - assert!(validate_score_pdf_source(&missing_extension).is_err()); - - let missing = root.join("missing.pdf"); - assert!(validate_score_pdf_source(&missing).is_err()); - - let oversized = root.join("oversized.pdf"); - { - let file = std::fs::File::create(&oversized).expect("oversized file should be created"); - let mut file = file; - file.write_all(b"%PDF-1.7") - .expect("oversized header should be written"); - file.set_len(MAX_SCORE_PDF_BYTES + 1) - .expect("oversized file should be extended"); - } - assert!(validate_score_pdf_source(&oversized).is_err()); - - #[cfg(unix)] - { - let symlinked = root.join("linked.pdf"); - std::os::unix::fs::symlink(&valid, &symlinked).expect("symlink should be created"); - assert!(validate_score_pdf_source(&symlinked).is_err()); - } - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn score_pdf_resolution_rejects_traversal_and_escapes() { - let scores_root = unique_test_dir("score-resolve"); - let outside_root = unique_test_dir("score-outside"); - std::fs::create_dir_all(&scores_root).expect("scores root should be created"); - std::fs::create_dir_all(&outside_root).expect("outside root should be created"); - - let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; - let inside_file = scores_root.join(format!("{score_id}.pdf")); - std::fs::write(&inside_file, b"%PDF-1.7").expect("inside file should be written"); - - let resolved = resolve_existing_score_pdf(&scores_root, score_id) - .expect("stored score inside the root should resolve"); - assert!(resolved.ends_with(format!("{score_id}.pdf"))); - - let directory_id = "22222222-3333-4444-5555-666666666666"; - std::fs::create_dir(scores_root.join(format!("{directory_id}.pdf"))) - .expect("directory named like a score should be created"); - assert!(resolve_existing_score_pdf(&scores_root, directory_id).is_err()); - - assert!(resolve_existing_score_pdf(&scores_root, "../escape").is_err()); - assert!(resolve_existing_score_pdf(&scores_root, "..").is_err()); - assert!( - resolve_existing_score_pdf(&scores_root, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - .is_err() - ); - - #[cfg(unix)] - { - let outside_file = outside_root.join("secret.pdf"); - std::fs::write(&outside_file, b"%PDF-1.7").expect("outside file should be written"); - let linked_id = "11111111-2222-3333-4444-555555555555"; - std::os::unix::fs::symlink(&outside_file, scores_root.join(format!("{linked_id}.pdf"))) - .expect("symlink should be created"); - assert!(resolve_existing_score_pdf(&scores_root, linked_id).is_err()); - } - - let _ = std::fs::remove_dir_all(scores_root); - let _ = std::fs::remove_dir_all(outside_root); - } -} diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs new file mode 100644 index 000000000..bf1ad1958 --- /dev/null +++ b/apps/desktop/core/src/project_format.rs @@ -0,0 +1,153 @@ +//! 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, Serialize)] +#[serde(rename_all = "camelCase")] +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}") +} + +/// 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(), + }) +} From 4aa18fa8cbe5e59cf3f1e195f9a20e51c36e4da7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:08:13 +0900 Subject: [PATCH 199/448] test(project): add golden v2 playback preference fixture --- apps/desktop/core/testdata/project-v2.json | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/desktop/core/testdata/project-v2.json 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" + } +} From 73dc9a7314c0e20938fc767c207e4102e1bbf106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:08:24 +0900 Subject: [PATCH 200/448] test(project): verify golden v2 playback preference fixture --- .../core/tests/project_format_v2_fixture.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/desktop/core/tests/project_format_v2_fixture.rs 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") + ); +} From 9518d84eb621b03211a4ad5a164969268ae68cdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:09:07 +0900 Subject: [PATCH 201/448] docs(project): describe v2 playback preference migration --- docs/engineering/local-project-format.md | 70 +++++++++++++++++------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 973082e22..254984be5 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,20 +1,22 @@ # 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. Current writes use a standard JSON envelope with `projectFormatVersion: 1`; the nested `song` is the current compatibility view used by the desktop contract. Older raw `RehearsalSong` JSON remains loadable as an explicit legacy input and is never silently rewritten in memory as a newer version. +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 (version 1) +### Top-Level Structure (version 2) ```json { - "projectFormatVersion": 1, + "projectFormatVersion": 2, "song": { "id": "string", "title": "string", @@ -32,17 +34,44 @@ The primary data structure for a `.bscope` file is the `RehearsalSong` type from "comments": [ ... ], "approvals": [ ... ] } + }, + "preferences": { + "selectedPlaybackSource": "full_mix" } } ``` -`tempo` and `collaboration` are optional. 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. +`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 current compatibility save command still receives only a validated song payload, so it writes version 2 with the deterministic `full_mix` default. The typed Project Persistence API can already serialize an explicit stable preference. Wiring the mounted Active Player selection into that typed document and resolving it through fresh native availability on reopen are separate consumer steps and are not claimed complete by the format migration 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 version is independent of the application package version. The v1 reader rejects unknown envelope fields and returns an explicit unsupported-version error for a well-formed future version. The checked-in golden fixture is `apps/desktop/core/testdata/project-v1.json`. +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. + +### 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 { @@ -60,7 +89,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 { @@ -81,26 +110,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 5 MiB, implemented as `5 * 1024 * 1024` bytes in the Tauri backend) to prevent memory exhaustion. -2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. Collaboration state tokens and `practiceProgress` use the same accepted domains as the shared renderer 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 1 deliberately keeps the existing validated `RehearsalSong` as the compatibility view. Source references, derived analysis artifacts, user decisions, portable handoff data, UI preferences, and volatile player state are not fabricated or written into untyped bags. Their typed promotion, bounded autosave journal, backup rotation, migration receipts, and accessible restore/compare/discard flow remain the next #962 slices. Player state must use this authority after the transport state machine is stable; it must not create a second localStorage or session persistence authority. +Version 2 establishes the first typed project preference and an executable v1 → v2 migration. 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 persist its selected semantic through the Project Persistence owner, then resolve that semantic against current native source availability on reopen. 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. -A selected playback source must be persisted as a stable project semantic such as `full_mix`, `vocals`, `bass`, `drums`, or `other`, never as a revocable `bandscope-playback` authority. Reload must resolve that semantic against current native availability and fail closed to Full mix if the prior source is unavailable. +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 must add an ordered migration from the prior envelope, validate a copy before publication, retain the prior known-good artifact, and update the machine-verifiable fixture. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded. +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. From ae97f885fbd91da1ec0958444b94e3af2fa68410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:09:44 +0900 Subject: [PATCH 202/448] docs(traceability): record v2 playback preference migration --- .../project-format-v2-playback-preference.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/traceability/project-format-v2-playback-preference.md 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..0c8029b7e --- /dev/null +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -0,0 +1,72 @@ +# 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. + +## 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. +- Unsupported future versions must fail explicitly before their body is interpreted as the current schema. +- 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 fix `be4ce61f9a865229aad9b46ad27adb79b1028258` isolates historical payload/process logic in `core`, introduces `project_format` as the current version/migration boundary, and makes crate-root Project Persistence APIs write/read version 2 while delegating v1 and legacy validation to the existing strict parser. The old `lib.rs` blob is reused exactly as `core.rs`; this is a module move, not a copied persistence 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. + +## 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 typed document API exists for the Active Player bridge to supply an explicit stable preference in the next consumer slice. + +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** — rejected because malformed, future, or injected values would survive as if they were current domain truth. +- **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. + +## Effect + +The canonical Project Persistence branch now has a typed current document with a versioned preference boundary and executable v1/legacy migration. Current Tauri song-only saves can emit a v2 document without persisting runtime media capability data. The change does not yet mean that a user-selected stem survives reopen: the #1160 consumer still has to pass the stable selection into the typed document, and reload still has to resolve it against fresh native availability. + +## Security Notes + +### Attack surface and trust boundary + +`.bscope` bytes remain untrusted local input. The new preference is admitted only after the versioned envelope crosses the native Project Persistence parser. 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 envelope uses `deny_unknown_fields`; `selectedPlaybackSource` is a serde enum with five accepted tokens. Unknown values and a literal `bandscope-playback://...` value fail parsing. V1/legacy inputs reuse the already hardened strict song parser rather than a permissive `serde_json::Value` migration. Future versions return `Unsupported project format version: ` before their future body is interpreted as v2. + +### Logging and privacy + +Migration errors are bounded format/version 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, and the checked-in v2 golden fixture. 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. + +### Remaining risk + +The current compatibility save command still receives only `RehearsalSong`, so it writes `full_mix` until the Active Player consumer is wired to the typed document API. 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. From e95b1db4495df5d9c721271f9b8edc54840eb004 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:14:37 +0900 Subject: [PATCH 203/448] refactor(project): keep historical core source in place --- apps/desktop/core/Cargo.toml | 4 +- apps/desktop/core/src/core.rs | 1663 -------------------------- apps/desktop/core/src/crate_root.rs | 17 + apps/desktop/core/src/lib.rs | 1674 ++++++++++++++++++++++++++- 4 files changed, 1680 insertions(+), 1678 deletions(-) delete mode 100644 apps/desktop/core/src/core.rs create mode 100644 apps/desktop/core/src/crate_root.rs 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/core.rs b/apps/desktop/core/src/core.rs deleted file mode 100644 index aaf2fc812..000000000 --- a/apps/desktop/core/src/core.rs +++ /dev/null @@ -1,1663 +0,0 @@ -//! Pure, GUI-independent logic for the BandScope desktop app. -//! -//! This crate holds every payload contract, validation guard, and process -//! helper that does not depend on Tauri or the WebView runtime. Keeping it -//! free of `tauri`/`wry` lets the full unit-test suite build and run (and be -//! measured for coverage) on any platform without a windowing system or a -//! bundled frontend. - -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use std::{ - collections::HashMap, - io::Read, - path::{Path, PathBuf}, - process::{Command, Stdio}, - sync::{ - atomic::{AtomicU64, AtomicUsize, Ordering}, - Arc, Mutex, - }, - thread, - time::{Duration, Instant}, -}; -use time::OffsetDateTime; - -#[derive(Clone)] -pub struct AppState(pub Arc); - -pub struct AppStateInner { - pub next_job: AtomicU64, - pub in_flight_jobs: AtomicUsize, - pub jobs: Mutex>, - pub bootstrap_sources: Mutex>, -} - -pub const MAX_IN_FLIGHT_JOBS: usize = 2; - -pub const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); - -pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); - -pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; - -pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; - -pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); - -pub const MAX_YOUTUBE_URL_LENGTH: usize = 2000; - -pub const MAX_SCORE_PDF_BYTES: u64 = 25 * 1024 * 1024; - -pub const PDF_MAGIC: &[u8] = b"%PDF-"; - -impl Default for AppState { - fn default() -> Self { - Self(Arc::new(AppStateInner { - next_job: AtomicU64::new(1), - in_flight_jobs: AtomicUsize::new(0), - jobs: Mutex::new(HashMap::new()), - bootstrap_sources: Mutex::new(HashMap::new()), - })) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobRequest { - pub source_kind: String, - pub project_id: Option, - pub source_label: String, - pub role_focus: Vec, - pub local_source: Option, - pub cache_root: Option, - pub temp_root: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobErrorCode { - InvalidRequest, - NotFound, - EngineUnavailable, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobError { - pub code: AnalysisJobErrorCode, - pub message: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobState { - Queued, - Running, - Succeeded, - Failed, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisJobStage { - Queued, - Decode, - Separate, - Analyze, - Persist, - Ready, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AnalysisCacheStatus { - Disabled, - Miss, - Hit, - Stored, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -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, - 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. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ScoreAttachmentMetadataPayload { - id: String, - 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: 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: CueKindPayload, - value: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RangePayload { - lowest_note: String, - highest_note: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HarmonyPayload { - chord: String, - function_label: 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: 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)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RehearsalRolePayload { - id: String, - name: 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: 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)] -#[serde(rename_all = "camelCase")] -pub struct SectionTimeRangePayload { - start: u32, - end: u32, -} - -impl<'de> Deserialize<'de> for SectionTimeRangePayload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct RawSectionTimeRangePayload { - start: u32, - end: u32, - } - - let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; - if raw.end <= raw.start { - return Err(serde::de::Error::custom( - "section timeRange end must be greater than start", - )); - } - - Ok(Self { - start: raw.start, - end: raw.end, - }) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct PartGraphNodePayload { - role_id: String, - is_active: bool, - handoff_to: Vec, - 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: SectionFormLabelPayload, - groove: String, - time_range: SectionTimeRangePayload, - confidence: ConfidencePayload, - roles: Vec, - 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: ExportFormatPayload, - headline: String, - focus_sections: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AnalysisJobStatus { - pub job_id: String, - pub state: AnalysisJobState, - pub requested_at: String, - pub updated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub progress_percent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LocalAudioSourcePayload { - pub source_path: String, - pub file_name: String, - pub extension: String, - pub file_size_bytes: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ProjectBootstrapSummaryPayload { - pub project_id: String, - pub source_mode: String, - pub project_root: String, - pub cache_root: String, - pub temp_root: String, - pub source: LocalAudioSourcePayload, -} - -pub fn next_project_id(state: &AppState) -> String { - format!( - "project-{}-{}", - OffsetDateTime::now_utc().unix_timestamp_nanos(), - state.0.next_job.fetch_add(1, Ordering::Relaxed) - ) -} - -pub fn youtube_source_from_metadata( - metadata: &Value, - cache_root: &Path, -) -> Result { - let filepath = metadata - .get("filepath") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; - let title = metadata - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or("Unknown YouTube Audio"); - let path = Path::new(filepath); - let link_metadata = std::fs::symlink_metadata(path) - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let canonical_cache_root = cache_root - .canonicalize() - .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; - #[cfg(coverage)] - let canonical = path - .canonicalize() - .expect("downloaded audio path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = path - .canonicalize() - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - if !canonical.starts_with(&canonical_cache_root) { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let file_metadata = link_metadata; - if !file_metadata.is_file() || file_metadata.len() == 0 { - return Err("YouTube import returned an invalid audio file.".to_string()); - } - - let extension = canonical - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; - if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { - return Err("YouTube import returned an unsupported audio format.".to_string()); - } - - let safe_title: String = title - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', - c if c.is_control() => '_', - c => c, - }) - .take(100) - .collect(); - let safe_title = if safe_title.is_empty() { - "youtube_audio".to_string() - } else { - safe_title - }; - - Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: format!("{safe_title}.{extension}"), - extension, - file_size_bytes: file_metadata.len(), - }) -} - -pub fn is_supported_youtube_url(url: &str) -> bool { - if url.len() > MAX_YOUTUBE_URL_LENGTH { - return false; - } - - let parsed_url = match url::Url::parse(url) { - Ok(u) => u, - Err(_) => return false, - }; - if parsed_url.scheme() != "https" { - return false; - } - - let host = parsed_url.host_str().unwrap_or("").to_lowercase(); - if host == "youtu.be" { - let mut segments = parsed_url - .path_segments() - .expect("https URLs should expose path segments") - .filter(|segment| !segment.is_empty()); - let Some(video_id) = segments.next() else { - return false; - }; - return is_youtube_video_id(video_id) && segments.next().is_none(); - } - - if host == "youtube.com" || host == "www.youtube.com" { - if parsed_url.path() != "/watch" { - return false; - } - let mut video_ids = parsed_url - .query_pairs() - .filter(|(key, _)| key == "v") - .map(|(_, value)| value); - return match (video_ids.next(), video_ids.next()) { - (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), - _ => false, - }; - } - - false -} - -pub fn youtube_missing_metadata_error(_parsed: &Value) -> String { - "YouTube import reported ok but missing metadata.".to_string() -} - -pub fn wait_for_process_output( - mut command: Command, - timeout: Duration, - poll_interval: Duration, - timeout_message: &str, -) -> Result { - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|_| "Failed to start YouTube import process.".to_string())?; - let stdout = child - .stdout - .take() - .expect("stdout should be piped for YouTube import process"); - let stderr = child - .stderr - .take() - .expect("stderr should be piped for YouTube import process"); - let stdout_reader = thread::spawn(move || { - let mut reader = stdout; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let stderr_reader = thread::spawn(move || { - let mut reader = stderr; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let deadline = Instant::now() + timeout; - - loop { - let process_status = { - #[cfg(coverage)] - { - child - .try_wait() - .expect("YouTube process status polling should not fail under coverage") - } - #[cfg(not(coverage))] - { - match child.try_wait() { - Ok(status) => status, - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err("Failed to execute YouTube import process.".to_string()); - } - } - } - }; - - match process_status { - Some(status) => { - #[cfg(coverage)] - let stdout = stdout_reader - .join() - .expect("stdout reader should not panic") - .expect("stdout reader should read process output"); - #[cfg(not(coverage))] - let stdout = stdout_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - #[cfg(coverage)] - let stderr = stderr_reader - .join() - .expect("stderr reader should not panic") - .expect("stderr reader should read process output"); - #[cfg(not(coverage))] - let stderr = stderr_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - return Ok(std::process::Output { - status, - stdout, - stderr, - }); - } - None => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(timeout_message.to_string()); - } - thread::sleep(poll_interval); - } - } - } -} - -pub fn is_youtube_video_id(value: &str) -> bool { - value.len() == 11 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') -} - -pub fn project_payload_from_content(content: &str) -> Result { - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; - - if let Some(version_value) = payload.get("projectFormatVersion") { - let version = version_value - .as_u64() - .ok_or_else(|| "Invalid project file format".to_string())?; - if version != u64::from(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); - } - - if let Some(sections) = payload.get("sections").and_then(Value::as_array) { - for (section_index, section) in sections.iter().enumerate() { - if section - .as_object() - .is_some_and(|section_object| !section_object.contains_key("timeRange")) - { - return Err(format!( - "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." - )); - } - } - } - - serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) -} - -/// Serialize one validated song into the current versioned project envelope. -pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> 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 { - pub score_id: String, - pub file_name: String, - pub file_size_bytes: u64, -} - -/// Security Notes: project ids never come from free-form user input. They are -/// only ever minted by `next_project_id` as `project--`, so -/// anything from the WebView that does not match that exact shape is rejected -/// before it can influence a filesystem path (no separators, no `..`). -pub fn is_valid_project_id(value: &str) -> bool { - let Some(rest) = value.strip_prefix("project-") else { - return false; - }; - let mut segments = rest.split('-'); - match (segments.next(), segments.next(), segments.next()) { - (Some(timestamp), Some(counter), None) => { - !timestamp.is_empty() - && !counter.is_empty() - && timestamp.bytes().all(|byte| byte.is_ascii_digit()) - && counter.bytes().all(|byte| byte.is_ascii_digit()) - } - _ => false, - } -} - -/// Security Notes: score ids are minted locally via UUID v4 and must round-trip -/// as exactly a lowercase hyphenated UUID (8-4-4-4-12). This is an allowlist -/// check, so path traversal payloads (`..`, separators, null bytes) can never -/// reach the path join below. -pub fn is_valid_score_id(value: &str) -> bool { - let bytes = value.as_bytes(); - if bytes.len() != 36 { - return false; - } - bytes.iter().enumerate().all(|(index, byte)| match index { - 8 | 13 | 18 | 23 => *byte == b'-', - _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), - }) -} - -/// Security Notes: the selected file is untrusted input (`User Input Boundary`). -/// We refuse symlinks before canonicalizing, require a real non-empty regular -/// file with a `.pdf` extension, cap the size at 25MB, and verify the `%PDF-` -/// magic bytes so a mislabeled file cannot be attached as a score. -pub fn validate_score_pdf_source(path: &Path) -> Result<(PathBuf, String, u64), String> { - let link_metadata = std::fs::symlink_metadata(path) - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("Could not read the selected PDF file.".to_string()); - } - - #[cfg(coverage)] - let canonical = path - .canonicalize() - .expect("score PDF path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = path - .canonicalize() - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - let extension = canonical - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; - if extension != "pdf" { - return Err("Choose a PDF file to attach as a score.".into()); - } - - let metadata = link_metadata; - if !metadata.is_file() || metadata.len() == 0 { - return Err("Could not read the selected PDF file.".into()); - } - if metadata.len() > MAX_SCORE_PDF_BYTES { - return Err("Score PDF is too large (exceeds 25MB limit).".into()); - } - - let mut header = [0u8; PDF_MAGIC.len()]; - std::fs::File::open(&canonical) - .and_then(|mut file| file.read_exact(&mut header)) - .map_err(|_| "Could not read the selected PDF file.".to_string())?; - if header != PDF_MAGIC { - return Err("The selected file is not a valid PDF.".into()); - } - - #[cfg(coverage)] - let file_name = canonical - .file_name() - .and_then(|value| value.to_str()) - .expect("canonical score PDF path should have a file name") - .to_string(); - #[cfg(not(coverage))] - let file_name = canonical - .file_name() - .and_then(|value| value.to_str()) - .map(|value| value.to_string()) - .ok_or_else(|| "Could not read the selected PDF file.".to_string())?; - - let file_size_bytes = metadata.len(); - Ok((canonical, file_name, file_size_bytes)) -} - -/// Security Notes: reads and deletes never accept an arbitrary path from the -/// WebView. The path is rebuilt server-side from validated ids, symlinks are -/// refused, and the canonicalized result must still live under the -/// canonicalized app-owned scores root (path-traversal guard). -pub fn resolve_existing_score_pdf(scores_root: &Path, score_id: &str) -> Result { - if !is_valid_score_id(score_id) { - return Err("Score was not found.".to_string()); - } - let candidate = scores_root.join(format!("{score_id}.pdf")); - let link_metadata = - std::fs::symlink_metadata(&candidate).map_err(|_| "Score was not found.".to_string())?; - #[cfg(not(all(coverage, windows)))] - if link_metadata.file_type().is_symlink() { - return Err("Score was not found.".to_string()); - } - - #[cfg(coverage)] - let canonical = candidate - .canonicalize() - .expect("stored score path should canonicalize after metadata lookup"); - #[cfg(not(coverage))] - let canonical = candidate - .canonicalize() - .map_err(|_| "Score was not found.".to_string())?; - #[cfg(not(coverage))] - { - let canonical_root = scores_root - .canonicalize() - .map_err(|_| "Score was not found.".to_string())?; - if !canonical.starts_with(&canonical_root) { - return Err("Score was not found.".to_string()); - } - } - - let metadata = link_metadata; - if !metadata.is_file() { - return Err("Score was not found.".to_string()); - } - Ok(canonical) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::io::Write; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_test_dir(name: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) - } - - fn shared_contract_payload(time_range: Value) -> Value { - json!({ - "id": "demo-song", - "title": "Late Night Set", - "sections": [ - { - "id": "verse-1", - "label": "verse", - "groove": "Straight eighths with a late snare feel", - "timeRange": time_range, - "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" - }, - "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.", - "manualOverrides": [], - "overlapWarnings": [ - "Density warning: competing with Keyboard Left Hand in low register." - ] - } - ], - "partGraph": [ - { - "role_id": "bass-guitar", - "is_active": true, - "handoff_to": ["lead-vocal"], - "handoff_from": [] - } - ] - } - ], - "exportSummary": { - "format": "cue-sheet", - "headline": "Start with the verse handoff and low-register overlap.", - "focusSections": ["verse-1"] - } - }) - } - - #[test] - fn rehearsal_song_payload_accepts_shared_section_contract() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - - let parsed = serde_json::from_value::(payload) - .expect("shared rehearsal song contract should deserialize in Tauri"); - - assert_eq!(parsed.sections[0].id, "verse-1"); - } - - #[test] - fn rehearsal_song_payload_round_trips_score_attachments() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["scoreAttachments"] = json!([ - { "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", "fileName": "opener.pdf" } - ]); - - let parsed = serde_json::from_value::(payload) - .expect("song payload with score attachments should deserialize"); - let attachments = parsed - .score_attachments - .as_ref() - .expect("score attachments should survive deserialization"); - assert_eq!(attachments[0].file_name, "opener.pdf"); - - let serialized = - serde_json::to_value(&parsed).expect("song payload should serialize back to JSON"); - assert_eq!( - serialized["scoreAttachments"][0]["fileName"], - json!("opener.pdf") - ); - } - - #[test] - fn rehearsal_song_payload_accepts_legacy_files_without_score_attachments() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - - let parsed = serde_json::from_value::(payload) - .expect("legacy payload without score attachments should deserialize"); - - assert!(parsed.score_attachments.is_none()); - let serialized = - serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); - assert!(serialized.get("scoreAttachments").is_none()); - } - - #[test] - fn rehearsal_song_payload_rejects_unknown_score_attachment_fields() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["scoreAttachments"] = json!([ - { - "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", - "fileName": "opener.pdf", - "sourcePath": "/etc/passwd" - } - ]); - - assert!(serde_json::from_value::(payload).is_err()); - } - - #[test] - fn rehearsal_song_payload_rejects_reversed_time_range() { - let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); - - assert!(serde_json::from_value::(payload).is_err()); - } - - #[test] - fn project_payload_from_content_rejects_legacy_missing_time_range() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["sections"][0] - .as_object_mut() - .expect("section should be an object") - .remove("timeRange"); - let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); - - let error = project_payload_from_content(&content) - .expect_err("legacy sections without timing should fail closed"); - - assert!(error.contains("timeRange")); - } - - #[test] - fn project_payload_from_content_accepts_current_contract() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - let content = serde_json::to_string(&payload).expect("payload should serialize"); - - let parsed = project_payload_from_content(&content) - .expect("current shared contract should parse directly"); - - 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!( - project_payload_from_content("{").expect_err("malformed JSON should fail"), - "Invalid project file format" - ); - - let error = project_payload_from_content(r#"{"sections":[]}"#) - .expect_err("incomplete payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = project_payload_from_content(r#"{"sections":[null]}"#) - .expect_err("malformed section entries should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = project_payload_from_content(r#"{"title":"Late Night Set"}"#) - .expect_err("sectionless payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - - let error = - project_payload_from_content(r#"{"sections":[{"timeRange":{"start":0,"end":1}}]}"#) - .expect_err("timed but incomplete payload should fail closed"); - assert_eq!(error, "Invalid project file format"); - } - - #[test] - fn youtube_url_validation_requires_exact_video_ids() { - assert!(is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url( - "https://www.youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); - - assert!(!is_supported_youtube_url( - "https://evil.youtube.com/watch?v=abc123DEF45" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF4!" - )); - assert!(!is_supported_youtube_url("https://youtube.com/watch")); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" - )); - assert!(!is_supported_youtube_url("https://youtu.be/abc123")); - assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); - } - - #[test] - fn youtube_url_validation_rejects_malformed_and_nonstandard_urls() { - assert!(!is_supported_youtube_url("not a url")); - assert!(!is_supported_youtube_url( - "http://youtube.com/watch?v=abc123DEF45" - )); - assert!(!is_supported_youtube_url("https://youtu.be/")); - assert!(!is_supported_youtube_url( - "https://youtube.com/embed/abc123DEF45" - )); - - let long_url = format!("https://youtube.com/watch?v={}", "a".repeat(2000)); - assert!(!is_supported_youtube_url(&long_url)); - } - - #[test] - fn youtube_missing_metadata_error_does_not_expose_payload() { - let parsed = json!({ - "ok": true, - "filepath": "/Users/someone/private-song.m4a", - "metadata": null - }); - - let message = youtube_missing_metadata_error(&parsed); - - assert_eq!(message, "YouTube import reported ok but missing metadata."); - assert!(!message.contains("private-song")); - assert!(!message.contains("filepath")); - } - - #[test] - fn youtube_process_timeout_kills_and_reaps_child() { - let command = long_sleep_command(); - - let result = wait_for_process_output( - command, - Duration::from_millis(50), - Duration::from_millis(5), - "YouTube import timed out.", - ); - - assert_eq!( - result.expect_err("slow child should time out"), - "YouTube import timed out." - ); - } - - #[test] - fn youtube_process_output_reports_spawn_failure() { - let command = Command::new(unique_test_dir("missing-youtube-command").join("missing-tool")); - - let result = wait_for_process_output( - command, - Duration::from_millis(50), - Duration::from_millis(5), - "YouTube import timed out.", - ); - - assert_eq!( - result.expect_err("missing helper should fail at spawn"), - "Failed to start YouTube import process." - ); - } - - fn long_sleep_command() -> Command { - #[cfg(windows)] - { - let mut command = Command::new("powershell"); - command - .arg("-NoProfile") - .arg("-Command") - .arg("Start-Sleep -Seconds 5"); - command - } - - #[cfg(not(windows))] - { - let mut command = Command::new("sh"); - command.arg("-c").arg("sleep 5"); - command - } - } - - #[test] - fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { - if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { - let chunk = vec![b'x'; 1024 * 1024]; - std::io::stdout() - .write_all(&chunk) - .expect("child stdout should accept test bytes"); - std::io::stderr() - .write_all(&chunk) - .expect("child stderr should accept test bytes"); - return; - } - - let current_test_binary = std::env::current_exe().expect("test binary should resolve"); - let mut command = Command::new(current_test_binary); - command - .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") - .arg("--exact") - .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") - .arg("--nocapture"); - - let output = wait_for_process_output( - command, - Duration::from_secs(2), - Duration::from_millis(5), - "YouTube import timed out.", - ) - .expect("large child output should be drained before timeout"); - - assert!(output.status.success()); - assert!(output.stdout.len() >= 1024 * 1024); - assert!(output.stderr.len() >= 1024 * 1024); - } - - #[test] - fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { - let cache_root = unique_test_dir("youtube-cache"); - let outside_root = unique_test_dir("youtube-outside"); - std::fs::create_dir_all(&cache_root).expect("cache root should be created"); - std::fs::create_dir_all(&outside_root).expect("outside root should be created"); - - let inside_file = cache_root.join("downloaded.m4a"); - let empty_file = cache_root.join("empty.m4a"); - let unsupported_file = cache_root.join("downloaded.txt"); - let no_extension_file = cache_root.join("downloaded"); - let outside_file = outside_root.join("downloaded.m4a"); - std::fs::write(&inside_file, b"audio").expect("inside file should be written"); - std::fs::write(&empty_file, b"").expect("empty file should be written"); - std::fs::write(&unsupported_file, b"not audio") - .expect("unsupported file should be written"); - std::fs::write(&no_extension_file, b"audio").expect("extensionless file should be written"); - std::fs::write(&outside_file, b"audio").expect("outside file should be written"); - - let accepted = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live/Test" }), - &cache_root, - ) - .expect("in-cache supported audio should be accepted"); - assert_eq!(accepted.extension, "m4a"); - assert_eq!(accepted.file_name, "Live_Test.m4a"); - - let default_title = - youtube_source_from_metadata(&json!({ "filepath": inside_file }), &cache_root) - .expect("missing YouTube title should use the default filename stem"); - assert_eq!(default_title.file_name, "Unknown YouTube Audio.m4a"); - - let empty_title = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "" }), - &cache_root, - ) - .expect("empty YouTube title should use the safe fallback filename stem"); - assert_eq!(empty_title.file_name, "youtube_audio.m4a"); - - let control_title = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live\u{0007}Bell" }), - &cache_root, - ) - .expect("control characters should be sanitized out of filenames"); - assert_eq!(control_title.file_name, "Live_Bell.m4a"); - - assert_eq!( - youtube_source_from_metadata(&json!({ "title": "Live" }), &cache_root) - .expect_err("missing filepath should fail closed"), - "Failed to parse YouTube import response." - ); - assert_eq!( - youtube_source_from_metadata( - &json!({ "filepath": cache_root.join("missing.m4a"), "title": "Live" }), - &cache_root, - ) - .expect_err("missing downloaded file should fail closed"), - "Could not read downloaded audio file." - ); - let missing_cache_root = unique_test_dir("youtube-missing-cache"); - assert_eq!( - youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live" }), - &missing_cache_root, - ) - .expect_err("missing cache root should fail closed"), - "Could not validate YouTube import workspace." - ); - assert!(youtube_source_from_metadata( - &json!({ "filepath": empty_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": unsupported_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": no_extension_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": outside_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - - #[cfg(unix)] - { - let symlink_file = cache_root.join("linked.m4a"); - std::os::unix::fs::symlink(&inside_file, &symlink_file) - .expect("symlink should be created"); - assert!(youtube_source_from_metadata( - &json!({ "filepath": symlink_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - } - - let _ = std::fs::remove_dir_all(cache_root); - let _ = std::fs::remove_dir_all(outside_root); - } - - #[test] - fn project_id_guard_accepts_generated_ids_only() { - let generated = next_project_id(&AppState::default()); - assert!(is_valid_project_id(&generated)); - assert!(is_valid_project_id("project-1751234567890123456-1")); - - assert!(!is_valid_project_id("")); - assert!(!is_valid_project_id("project-")); - assert!(!is_valid_project_id("project-123")); - assert!(!is_valid_project_id("project-123-")); - assert!(!is_valid_project_id("project-123-4-5")); - assert!(!is_valid_project_id("project-abc-1")); - assert!(!is_valid_project_id("project-123-1x")); - assert!(!is_valid_project_id("other-123-1")); - assert!(!is_valid_project_id("../project-123-1")); - assert!(!is_valid_project_id("project-123-1/..")); - assert!(!is_valid_project_id("project-..-1")); - assert!(!is_valid_project_id("project-123-1/escape")); - } - - #[test] - fn score_id_guard_accepts_lowercase_uuid_v4_only() { - let generated = uuid::Uuid::new_v4().to_string(); - assert!(is_valid_score_id(&generated)); - assert!(is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e160355e")); - - assert!(!is_valid_score_id("")); - assert!(!is_valid_score_id("not-a-uuid")); - assert!(!is_valid_score_id("6FA459EA-EE8A-3CA4-894E-DB77E160355E")); - assert!(!is_valid_score_id("6fa459eaee8a3ca4894edb77e160355e")); - assert!(!is_valid_score_id("{6fa459ea-ee8a-3ca4-894e-db77e160355e}")); - assert!(!is_valid_score_id("../../../../etc/passwd-aaaa-bbbb-cc")); - assert!(!is_valid_score_id( - "6fa459ea-ee8a-3ca4-894e-db77e160355e/.." - )); - assert!(!is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e16035/e")); - } - - #[test] - fn score_pdf_source_requires_pdf_magic_size_and_real_file() { - let root = unique_test_dir("score-source"); - std::fs::create_dir_all(&root).expect("score source root should be created"); - - let valid = root.join("score.pdf"); - std::fs::write(&valid, b"%PDF-1.7 fake body").expect("valid pdf should be written"); - let (canonical, file_name, size) = - validate_score_pdf_source(&valid).expect("valid pdf should be accepted"); - assert_eq!(file_name, "score.pdf"); - assert_eq!(size, 18); - assert!(canonical.ends_with("score.pdf")); - - let wrong_magic = root.join("not-really.pdf"); - std::fs::write(&wrong_magic, b"PK\x03\x04 zip bytes") - .expect("wrong magic file should be written"); - assert!(validate_score_pdf_source(&wrong_magic).is_err()); - - let short = root.join("short.pdf"); - std::fs::write(&short, b"%PD").expect("short file should be written"); - assert!(validate_score_pdf_source(&short).is_err()); - - let empty = root.join("empty.pdf"); - std::fs::write(&empty, b"").expect("empty file should be written"); - assert!(validate_score_pdf_source(&empty).is_err()); - - let wrong_extension = root.join("score.txt"); - std::fs::write(&wrong_extension, b"%PDF-1.7").expect("txt file should be written"); - assert!(validate_score_pdf_source(&wrong_extension).is_err()); - - let missing_extension = root.join("score"); - std::fs::write(&missing_extension, b"%PDF-1.7") - .expect("extensionless score file should be written"); - assert!(validate_score_pdf_source(&missing_extension).is_err()); - - let missing = root.join("missing.pdf"); - assert!(validate_score_pdf_source(&missing).is_err()); - - let oversized = root.join("oversized.pdf"); - { - let file = std::fs::File::create(&oversized).expect("oversized file should be created"); - let mut file = file; - file.write_all(b"%PDF-1.7") - .expect("oversized header should be written"); - file.set_len(MAX_SCORE_PDF_BYTES + 1) - .expect("oversized file should be extended"); - } - assert!(validate_score_pdf_source(&oversized).is_err()); - - #[cfg(unix)] - { - let symlinked = root.join("linked.pdf"); - std::os::unix::fs::symlink(&valid, &symlinked).expect("symlink should be created"); - assert!(validate_score_pdf_source(&symlinked).is_err()); - } - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn score_pdf_resolution_rejects_traversal_and_escapes() { - let scores_root = unique_test_dir("score-resolve"); - let outside_root = unique_test_dir("score-outside"); - std::fs::create_dir_all(&scores_root).expect("scores root should be created"); - std::fs::create_dir_all(&outside_root).expect("outside root should be created"); - - let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; - let inside_file = scores_root.join(format!("{score_id}.pdf")); - std::fs::write(&inside_file, b"%PDF-1.7").expect("inside file should be written"); - - let resolved = resolve_existing_score_pdf(&scores_root, score_id) - .expect("stored score inside the root should resolve"); - assert!(resolved.ends_with(format!("{score_id}.pdf"))); - - let directory_id = "22222222-3333-4444-5555-666666666666"; - std::fs::create_dir(scores_root.join(format!("{directory_id}.pdf"))) - .expect("directory named like a score should be created"); - assert!(resolve_existing_score_pdf(&scores_root, directory_id).is_err()); - - assert!(resolve_existing_score_pdf(&scores_root, "../escape").is_err()); - assert!(resolve_existing_score_pdf(&scores_root, "..").is_err()); - assert!( - resolve_existing_score_pdf(&scores_root, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - .is_err() - ); - - #[cfg(unix)] - { - let outside_file = outside_root.join("secret.pdf"); - std::fs::write(&outside_file, b"%PDF-1.7").expect("outside file should be written"); - let linked_id = "11111111-2222-3333-4444-555555555555"; - std::os::unix::fs::symlink(&outside_file, scores_root.join(format!("{linked_id}.pdf"))) - .expect("symlink should be created"); - assert!(resolve_existing_score_pdf(&scores_root, linked_id).is_err()); - } - - let _ = std::fs::remove_dir_all(scores_root); - let _ = std::fs::remove_dir_all(outside_root); - } -} diff --git a/apps/desktop/core/src/crate_root.rs b/apps/desktop/core/src/crate_root.rs new file mode 100644 index 000000000..65d69805d --- /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_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 671e1a555..aaf2fc812 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -1,15 +1,1663 @@ -//! Public crate root for GUI-independent BandScope desktop logic. +//! Pure, GUI-independent logic for the BandScope desktop app. //! -//! The historical payload/process surface remains in `core`; Project -//! Persistence format evolution is isolated in `project_format` so durable -//! migration rules do not become another renderer or Tauri storage authority. - -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_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, - SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, +//! This crate holds every payload contract, validation guard, and process +//! helper that does not depend on Tauri or the WebView runtime. Keeping it +//! free of `tauri`/`wry` lets the full unit-test suite build and run (and be +//! measured for coverage) on any platform without a windowing system or a +//! bundled frontend. + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, }; +use time::OffsetDateTime; + +#[derive(Clone)] +pub struct AppState(pub Arc); + +pub struct AppStateInner { + pub next_job: AtomicU64, + pub in_flight_jobs: AtomicUsize, + pub jobs: Mutex>, + pub bootstrap_sources: Mutex>, +} + +pub const MAX_IN_FLIGHT_JOBS: usize = 2; + +pub const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); + +pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); + +pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; + +pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; + +pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); + +pub const MAX_YOUTUBE_URL_LENGTH: usize = 2000; + +pub const MAX_SCORE_PDF_BYTES: u64 = 25 * 1024 * 1024; + +pub const PDF_MAGIC: &[u8] = b"%PDF-"; + +impl Default for AppState { + fn default() -> Self { + Self(Arc::new(AppStateInner { + next_job: AtomicU64::new(1), + in_flight_jobs: AtomicUsize::new(0), + jobs: Mutex::new(HashMap::new()), + bootstrap_sources: Mutex::new(HashMap::new()), + })) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobRequest { + pub source_kind: String, + pub project_id: Option, + pub source_label: String, + pub role_focus: Vec, + pub local_source: Option, + pub cache_root: Option, + pub temp_root: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobErrorCode { + InvalidRequest, + NotFound, + EngineUnavailable, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobError { + pub code: AnalysisJobErrorCode, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobState { + Queued, + Running, + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobStage { + Queued, + Decode, + Separate, + Analyze, + Persist, + Ready, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisCacheStatus { + Disabled, + Miss, + Hit, + Stored, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +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, + 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. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScoreAttachmentMetadataPayload { + id: String, + 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: 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: CueKindPayload, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RangePayload { + lowest_note: String, + highest_note: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HarmonyPayload { + chord: String, + function_label: 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: 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)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalRolePayload { + id: String, + name: 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: 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)] +#[serde(rename_all = "camelCase")] +pub struct SectionTimeRangePayload { + start: u32, + end: u32, +} + +impl<'de> Deserialize<'de> for SectionTimeRangePayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct RawSectionTimeRangePayload { + start: u32, + end: u32, + } + + let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; + if raw.end <= raw.start { + return Err(serde::de::Error::custom( + "section timeRange end must be greater than start", + )); + } + + Ok(Self { + start: raw.start, + end: raw.end, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartGraphNodePayload { + role_id: String, + is_active: bool, + handoff_to: Vec, + 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: SectionFormLabelPayload, + groove: String, + time_range: SectionTimeRangePayload, + confidence: ConfidencePayload, + roles: Vec, + 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: ExportFormatPayload, + headline: String, + focus_sections: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobStatus { + pub job_id: String, + pub state: AnalysisJobState, + pub requested_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAudioSourcePayload { + pub source_path: String, + pub file_name: String, + pub extension: String, + pub file_size_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectBootstrapSummaryPayload { + pub project_id: String, + pub source_mode: String, + pub project_root: String, + pub cache_root: String, + pub temp_root: String, + pub source: LocalAudioSourcePayload, +} + +pub fn next_project_id(state: &AppState) -> String { + format!( + "project-{}-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos(), + state.0.next_job.fetch_add(1, Ordering::Relaxed) + ) +} + +pub fn youtube_source_from_metadata( + metadata: &Value, + cache_root: &Path, +) -> Result { + let filepath = metadata + .get("filepath") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; + let title = metadata + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown YouTube Audio"); + let path = Path::new(filepath); + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let canonical_cache_root = cache_root + .canonicalize() + .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; + #[cfg(coverage)] + let canonical = path + .canonicalize() + .expect("downloaded audio path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = path + .canonicalize() + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !canonical.starts_with(&canonical_cache_root) { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let file_metadata = link_metadata; + if !file_metadata.is_file() || file_metadata.len() == 0 { + return Err("YouTube import returned an invalid audio file.".to_string()); + } + + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { + return Err("YouTube import returned an unsupported audio format.".to_string()); + } + + let safe_title: String = title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', + c if c.is_control() => '_', + c => c, + }) + .take(100) + .collect(); + let safe_title = if safe_title.is_empty() { + "youtube_audio".to_string() + } else { + safe_title + }; + + Ok(LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: format!("{safe_title}.{extension}"), + extension, + file_size_bytes: file_metadata.len(), + }) +} + +pub fn is_supported_youtube_url(url: &str) -> bool { + if url.len() > MAX_YOUTUBE_URL_LENGTH { + return false; + } + + let parsed_url = match url::Url::parse(url) { + Ok(u) => u, + Err(_) => return false, + }; + if parsed_url.scheme() != "https" { + return false; + } + + let host = parsed_url.host_str().unwrap_or("").to_lowercase(); + if host == "youtu.be" { + let mut segments = parsed_url + .path_segments() + .expect("https URLs should expose path segments") + .filter(|segment| !segment.is_empty()); + let Some(video_id) = segments.next() else { + return false; + }; + return is_youtube_video_id(video_id) && segments.next().is_none(); + } + + if host == "youtube.com" || host == "www.youtube.com" { + if parsed_url.path() != "/watch" { + return false; + } + let mut video_ids = parsed_url + .query_pairs() + .filter(|(key, _)| key == "v") + .map(|(_, value)| value); + return match (video_ids.next(), video_ids.next()) { + (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), + _ => false, + }; + } + + false +} + +pub fn youtube_missing_metadata_error(_parsed: &Value) -> String { + "YouTube import reported ok but missing metadata.".to_string() +} + +pub fn wait_for_process_output( + mut command: Command, + timeout: Duration, + poll_interval: Duration, + timeout_message: &str, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "Failed to start YouTube import process.".to_string())?; + let stdout = child + .stdout + .take() + .expect("stdout should be piped for YouTube import process"); + let stderr = child + .stderr + .take() + .expect("stderr should be piped for YouTube import process"); + let stdout_reader = thread::spawn(move || { + let mut reader = stdout; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let stderr_reader = thread::spawn(move || { + let mut reader = stderr; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let deadline = Instant::now() + timeout; + + loop { + let process_status = { + #[cfg(coverage)] + { + child + .try_wait() + .expect("YouTube process status polling should not fail under coverage") + } + #[cfg(not(coverage))] + { + match child.try_wait() { + Ok(status) => status, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("Failed to execute YouTube import process.".to_string()); + } + } + } + }; + + match process_status { + Some(status) => { + #[cfg(coverage)] + let stdout = stdout_reader + .join() + .expect("stdout reader should not panic") + .expect("stdout reader should read process output"); + #[cfg(not(coverage))] + let stdout = stdout_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + #[cfg(coverage)] + let stderr = stderr_reader + .join() + .expect("stderr reader should not panic") + .expect("stderr reader should read process output"); + #[cfg(not(coverage))] + let stderr = stderr_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + return Ok(std::process::Output { + status, + stdout, + stderr, + }); + } + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(timeout_message.to_string()); + } + thread::sleep(poll_interval); + } + } + } +} + +pub fn is_youtube_video_id(value: &str) -> bool { + value.len() == 11 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +pub fn project_payload_from_content(content: &str) -> Result { + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + + if let Some(version_value) = payload.get("projectFormatVersion") { + let version = version_value + .as_u64() + .ok_or_else(|| "Invalid project file format".to_string())?; + if version != u64::from(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); + } + + if let Some(sections) = payload.get("sections").and_then(Value::as_array) { + for (section_index, section) in sections.iter().enumerate() { + if section + .as_object() + .is_some_and(|section_object| !section_object.contains_key("timeRange")) + { + return Err(format!( + "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." + )); + } + } + } + + serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) +} + +/// Serialize one validated song into the current versioned project envelope. +pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> 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 { + pub score_id: String, + pub file_name: String, + pub file_size_bytes: u64, +} + +/// Security Notes: project ids never come from free-form user input. They are +/// only ever minted by `next_project_id` as `project--`, so +/// anything from the WebView that does not match that exact shape is rejected +/// before it can influence a filesystem path (no separators, no `..`). +pub fn is_valid_project_id(value: &str) -> bool { + let Some(rest) = value.strip_prefix("project-") else { + return false; + }; + let mut segments = rest.split('-'); + match (segments.next(), segments.next(), segments.next()) { + (Some(timestamp), Some(counter), None) => { + !timestamp.is_empty() + && !counter.is_empty() + && timestamp.bytes().all(|byte| byte.is_ascii_digit()) + && counter.bytes().all(|byte| byte.is_ascii_digit()) + } + _ => false, + } +} + +/// Security Notes: score ids are minted locally via UUID v4 and must round-trip +/// as exactly a lowercase hyphenated UUID (8-4-4-4-12). This is an allowlist +/// check, so path traversal payloads (`..`, separators, null bytes) can never +/// reach the path join below. +pub fn is_valid_score_id(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }) +} + +/// Security Notes: the selected file is untrusted input (`User Input Boundary`). +/// We refuse symlinks before canonicalizing, require a real non-empty regular +/// file with a `.pdf` extension, cap the size at 25MB, and verify the `%PDF-` +/// magic bytes so a mislabeled file cannot be attached as a score. +pub fn validate_score_pdf_source(path: &Path) -> Result<(PathBuf, String, u64), String> { + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("Could not read the selected PDF file.".to_string()); + } + + #[cfg(coverage)] + let canonical = path + .canonicalize() + .expect("score PDF path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = path + .canonicalize() + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; + if extension != "pdf" { + return Err("Choose a PDF file to attach as a score.".into()); + } + + let metadata = link_metadata; + if !metadata.is_file() || metadata.len() == 0 { + return Err("Could not read the selected PDF file.".into()); + } + if metadata.len() > MAX_SCORE_PDF_BYTES { + return Err("Score PDF is too large (exceeds 25MB limit).".into()); + } + + let mut header = [0u8; PDF_MAGIC.len()]; + std::fs::File::open(&canonical) + .and_then(|mut file| file.read_exact(&mut header)) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + if header != PDF_MAGIC { + return Err("The selected file is not a valid PDF.".into()); + } + + #[cfg(coverage)] + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .expect("canonical score PDF path should have a file name") + .to_string(); + #[cfg(not(coverage))] + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .ok_or_else(|| "Could not read the selected PDF file.".to_string())?; + + let file_size_bytes = metadata.len(); + Ok((canonical, file_name, file_size_bytes)) +} + +/// Security Notes: reads and deletes never accept an arbitrary path from the +/// WebView. The path is rebuilt server-side from validated ids, symlinks are +/// refused, and the canonicalized result must still live under the +/// canonicalized app-owned scores root (path-traversal guard). +pub fn resolve_existing_score_pdf(scores_root: &Path, score_id: &str) -> Result { + if !is_valid_score_id(score_id) { + return Err("Score was not found.".to_string()); + } + let candidate = scores_root.join(format!("{score_id}.pdf")); + let link_metadata = + std::fs::symlink_metadata(&candidate).map_err(|_| "Score was not found.".to_string())?; + #[cfg(not(all(coverage, windows)))] + if link_metadata.file_type().is_symlink() { + return Err("Score was not found.".to_string()); + } + + #[cfg(coverage)] + let canonical = candidate + .canonicalize() + .expect("stored score path should canonicalize after metadata lookup"); + #[cfg(not(coverage))] + let canonical = candidate + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + #[cfg(not(coverage))] + { + let canonical_root = scores_root + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + if !canonical.starts_with(&canonical_root) { + return Err("Score was not found.".to_string()); + } + } + + let metadata = link_metadata; + if !metadata.is_file() { + return Err("Score was not found.".to_string()); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) + } + + fn shared_contract_payload(time_range: Value) -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": time_range, + "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" + }, + "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.", + "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": ["lead-vocal"], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + } + }) + } + + #[test] + fn rehearsal_song_payload_accepts_shared_section_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("shared rehearsal song contract should deserialize in Tauri"); + + assert_eq!(parsed.sections[0].id, "verse-1"); + } + + #[test] + fn rehearsal_song_payload_round_trips_score_attachments() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", "fileName": "opener.pdf" } + ]); + + let parsed = serde_json::from_value::(payload) + .expect("song payload with score attachments should deserialize"); + let attachments = parsed + .score_attachments + .as_ref() + .expect("score attachments should survive deserialization"); + assert_eq!(attachments[0].file_name, "opener.pdf"); + + let serialized = + serde_json::to_value(&parsed).expect("song payload should serialize back to JSON"); + assert_eq!( + serialized["scoreAttachments"][0]["fileName"], + json!("opener.pdf") + ); + } + + #[test] + fn rehearsal_song_payload_accepts_legacy_files_without_score_attachments() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("legacy payload without score attachments should deserialize"); + + assert!(parsed.score_attachments.is_none()); + let serialized = + serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); + assert!(serialized.get("scoreAttachments").is_none()); + } + + #[test] + fn rehearsal_song_payload_rejects_unknown_score_attachment_fields() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { + "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", + "fileName": "opener.pdf", + "sourcePath": "/etc/passwd" + } + ]); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn rehearsal_song_payload_rejects_reversed_time_range() { + let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn project_payload_from_content_rejects_legacy_missing_time_range() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["sections"][0] + .as_object_mut() + .expect("section should be an object") + .remove("timeRange"); + let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); + + let error = project_payload_from_content(&content) + .expect_err("legacy sections without timing should fail closed"); + + assert!(error.contains("timeRange")); + } + + #[test] + fn project_payload_from_content_accepts_current_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + let content = serde_json::to_string(&payload).expect("payload should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("current shared contract should parse directly"); + + 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!( + project_payload_from_content("{").expect_err("malformed JSON should fail"), + "Invalid project file format" + ); + + let error = project_payload_from_content(r#"{"sections":[]}"#) + .expect_err("incomplete payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = project_payload_from_content(r#"{"sections":[null]}"#) + .expect_err("malformed section entries should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = project_payload_from_content(r#"{"title":"Late Night Set"}"#) + .expect_err("sectionless payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + + let error = + project_payload_from_content(r#"{"sections":[{"timeRange":{"start":0,"end":1}}]}"#) + .expect_err("timed but incomplete payload should fail closed"); + assert_eq!(error, "Invalid project file format"); + } + + #[test] + fn youtube_url_validation_requires_exact_video_ids() { + assert!(is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url( + "https://www.youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); + + assert!(!is_supported_youtube_url( + "https://evil.youtube.com/watch?v=abc123DEF45" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF4!" + )); + assert!(!is_supported_youtube_url("https://youtube.com/watch")); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" + )); + assert!(!is_supported_youtube_url("https://youtu.be/abc123")); + assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); + } + + #[test] + fn youtube_url_validation_rejects_malformed_and_nonstandard_urls() { + assert!(!is_supported_youtube_url("not a url")); + assert!(!is_supported_youtube_url( + "http://youtube.com/watch?v=abc123DEF45" + )); + assert!(!is_supported_youtube_url("https://youtu.be/")); + assert!(!is_supported_youtube_url( + "https://youtube.com/embed/abc123DEF45" + )); + + let long_url = format!("https://youtube.com/watch?v={}", "a".repeat(2000)); + assert!(!is_supported_youtube_url(&long_url)); + } + + #[test] + fn youtube_missing_metadata_error_does_not_expose_payload() { + let parsed = json!({ + "ok": true, + "filepath": "/Users/someone/private-song.m4a", + "metadata": null + }); + + let message = youtube_missing_metadata_error(&parsed); + + assert_eq!(message, "YouTube import reported ok but missing metadata."); + assert!(!message.contains("private-song")); + assert!(!message.contains("filepath")); + } + + #[test] + fn youtube_process_timeout_kills_and_reaps_child() { + let command = long_sleep_command(); + + let result = wait_for_process_output( + command, + Duration::from_millis(50), + Duration::from_millis(5), + "YouTube import timed out.", + ); + + assert_eq!( + result.expect_err("slow child should time out"), + "YouTube import timed out." + ); + } + + #[test] + fn youtube_process_output_reports_spawn_failure() { + let command = Command::new(unique_test_dir("missing-youtube-command").join("missing-tool")); + + let result = wait_for_process_output( + command, + Duration::from_millis(50), + Duration::from_millis(5), + "YouTube import timed out.", + ); + + assert_eq!( + result.expect_err("missing helper should fail at spawn"), + "Failed to start YouTube import process." + ); + } + + fn long_sleep_command() -> Command { + #[cfg(windows)] + { + let mut command = Command::new("powershell"); + command + .arg("-NoProfile") + .arg("-Command") + .arg("Start-Sleep -Seconds 5"); + command + } + + #[cfg(not(windows))] + { + let mut command = Command::new("sh"); + command.arg("-c").arg("sleep 5"); + command + } + } + + #[test] + fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { + if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { + let chunk = vec![b'x'; 1024 * 1024]; + std::io::stdout() + .write_all(&chunk) + .expect("child stdout should accept test bytes"); + std::io::stderr() + .write_all(&chunk) + .expect("child stderr should accept test bytes"); + return; + } + + let current_test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut command = Command::new(current_test_binary); + command + .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") + .arg("--exact") + .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") + .arg("--nocapture"); + + let output = wait_for_process_output( + command, + Duration::from_secs(2), + Duration::from_millis(5), + "YouTube import timed out.", + ) + .expect("large child output should be drained before timeout"); + + assert!(output.status.success()); + assert!(output.stdout.len() >= 1024 * 1024); + assert!(output.stderr.len() >= 1024 * 1024); + } + + #[test] + fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { + let cache_root = unique_test_dir("youtube-cache"); + let outside_root = unique_test_dir("youtube-outside"); + std::fs::create_dir_all(&cache_root).expect("cache root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let inside_file = cache_root.join("downloaded.m4a"); + let empty_file = cache_root.join("empty.m4a"); + let unsupported_file = cache_root.join("downloaded.txt"); + let no_extension_file = cache_root.join("downloaded"); + let outside_file = outside_root.join("downloaded.m4a"); + std::fs::write(&inside_file, b"audio").expect("inside file should be written"); + std::fs::write(&empty_file, b"").expect("empty file should be written"); + std::fs::write(&unsupported_file, b"not audio") + .expect("unsupported file should be written"); + std::fs::write(&no_extension_file, b"audio").expect("extensionless file should be written"); + std::fs::write(&outside_file, b"audio").expect("outside file should be written"); + + let accepted = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live/Test" }), + &cache_root, + ) + .expect("in-cache supported audio should be accepted"); + assert_eq!(accepted.extension, "m4a"); + assert_eq!(accepted.file_name, "Live_Test.m4a"); + + let default_title = + youtube_source_from_metadata(&json!({ "filepath": inside_file }), &cache_root) + .expect("missing YouTube title should use the default filename stem"); + assert_eq!(default_title.file_name, "Unknown YouTube Audio.m4a"); + + let empty_title = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "" }), + &cache_root, + ) + .expect("empty YouTube title should use the safe fallback filename stem"); + assert_eq!(empty_title.file_name, "youtube_audio.m4a"); + + let control_title = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live\u{0007}Bell" }), + &cache_root, + ) + .expect("control characters should be sanitized out of filenames"); + assert_eq!(control_title.file_name, "Live_Bell.m4a"); + + assert_eq!( + youtube_source_from_metadata(&json!({ "title": "Live" }), &cache_root) + .expect_err("missing filepath should fail closed"), + "Failed to parse YouTube import response." + ); + assert_eq!( + youtube_source_from_metadata( + &json!({ "filepath": cache_root.join("missing.m4a"), "title": "Live" }), + &cache_root, + ) + .expect_err("missing downloaded file should fail closed"), + "Could not read downloaded audio file." + ); + let missing_cache_root = unique_test_dir("youtube-missing-cache"); + assert_eq!( + youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live" }), + &missing_cache_root, + ) + .expect_err("missing cache root should fail closed"), + "Could not validate YouTube import workspace." + ); + assert!(youtube_source_from_metadata( + &json!({ "filepath": empty_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": unsupported_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": no_extension_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": outside_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + + #[cfg(unix)] + { + let symlink_file = cache_root.join("linked.m4a"); + std::os::unix::fs::symlink(&inside_file, &symlink_file) + .expect("symlink should be created"); + assert!(youtube_source_from_metadata( + &json!({ "filepath": symlink_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + } + + let _ = std::fs::remove_dir_all(cache_root); + let _ = std::fs::remove_dir_all(outside_root); + } + + #[test] + fn project_id_guard_accepts_generated_ids_only() { + let generated = next_project_id(&AppState::default()); + assert!(is_valid_project_id(&generated)); + assert!(is_valid_project_id("project-1751234567890123456-1")); + + assert!(!is_valid_project_id("")); + assert!(!is_valid_project_id("project-")); + assert!(!is_valid_project_id("project-123")); + assert!(!is_valid_project_id("project-123-")); + assert!(!is_valid_project_id("project-123-4-5")); + assert!(!is_valid_project_id("project-abc-1")); + assert!(!is_valid_project_id("project-123-1x")); + assert!(!is_valid_project_id("other-123-1")); + assert!(!is_valid_project_id("../project-123-1")); + assert!(!is_valid_project_id("project-123-1/..")); + assert!(!is_valid_project_id("project-..-1")); + assert!(!is_valid_project_id("project-123-1/escape")); + } + + #[test] + fn score_id_guard_accepts_lowercase_uuid_v4_only() { + let generated = uuid::Uuid::new_v4().to_string(); + assert!(is_valid_score_id(&generated)); + assert!(is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e160355e")); + + assert!(!is_valid_score_id("")); + assert!(!is_valid_score_id("not-a-uuid")); + assert!(!is_valid_score_id("6FA459EA-EE8A-3CA4-894E-DB77E160355E")); + assert!(!is_valid_score_id("6fa459eaee8a3ca4894edb77e160355e")); + assert!(!is_valid_score_id("{6fa459ea-ee8a-3ca4-894e-db77e160355e}")); + assert!(!is_valid_score_id("../../../../etc/passwd-aaaa-bbbb-cc")); + assert!(!is_valid_score_id( + "6fa459ea-ee8a-3ca4-894e-db77e160355e/.." + )); + assert!(!is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e16035/e")); + } + + #[test] + fn score_pdf_source_requires_pdf_magic_size_and_real_file() { + let root = unique_test_dir("score-source"); + std::fs::create_dir_all(&root).expect("score source root should be created"); + + let valid = root.join("score.pdf"); + std::fs::write(&valid, b"%PDF-1.7 fake body").expect("valid pdf should be written"); + let (canonical, file_name, size) = + validate_score_pdf_source(&valid).expect("valid pdf should be accepted"); + assert_eq!(file_name, "score.pdf"); + assert_eq!(size, 18); + assert!(canonical.ends_with("score.pdf")); + + let wrong_magic = root.join("not-really.pdf"); + std::fs::write(&wrong_magic, b"PK\x03\x04 zip bytes") + .expect("wrong magic file should be written"); + assert!(validate_score_pdf_source(&wrong_magic).is_err()); + + let short = root.join("short.pdf"); + std::fs::write(&short, b"%PD").expect("short file should be written"); + assert!(validate_score_pdf_source(&short).is_err()); + + let empty = root.join("empty.pdf"); + std::fs::write(&empty, b"").expect("empty file should be written"); + assert!(validate_score_pdf_source(&empty).is_err()); + + let wrong_extension = root.join("score.txt"); + std::fs::write(&wrong_extension, b"%PDF-1.7").expect("txt file should be written"); + assert!(validate_score_pdf_source(&wrong_extension).is_err()); + + let missing_extension = root.join("score"); + std::fs::write(&missing_extension, b"%PDF-1.7") + .expect("extensionless score file should be written"); + assert!(validate_score_pdf_source(&missing_extension).is_err()); + + let missing = root.join("missing.pdf"); + assert!(validate_score_pdf_source(&missing).is_err()); + + let oversized = root.join("oversized.pdf"); + { + let file = std::fs::File::create(&oversized).expect("oversized file should be created"); + let mut file = file; + file.write_all(b"%PDF-1.7") + .expect("oversized header should be written"); + file.set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("oversized file should be extended"); + } + assert!(validate_score_pdf_source(&oversized).is_err()); + + #[cfg(unix)] + { + let symlinked = root.join("linked.pdf"); + std::os::unix::fs::symlink(&valid, &symlinked).expect("symlink should be created"); + assert!(validate_score_pdf_source(&symlinked).is_err()); + } + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn score_pdf_resolution_rejects_traversal_and_escapes() { + let scores_root = unique_test_dir("score-resolve"); + let outside_root = unique_test_dir("score-outside"); + std::fs::create_dir_all(&scores_root).expect("scores root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + let inside_file = scores_root.join(format!("{score_id}.pdf")); + std::fs::write(&inside_file, b"%PDF-1.7").expect("inside file should be written"); + + let resolved = resolve_existing_score_pdf(&scores_root, score_id) + .expect("stored score inside the root should resolve"); + assert!(resolved.ends_with(format!("{score_id}.pdf"))); + + let directory_id = "22222222-3333-4444-5555-666666666666"; + std::fs::create_dir(scores_root.join(format!("{directory_id}.pdf"))) + .expect("directory named like a score should be created"); + assert!(resolve_existing_score_pdf(&scores_root, directory_id).is_err()); + + assert!(resolve_existing_score_pdf(&scores_root, "../escape").is_err()); + assert!(resolve_existing_score_pdf(&scores_root, "..").is_err()); + assert!( + resolve_existing_score_pdf(&scores_root, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + .is_err() + ); + + #[cfg(unix)] + { + let outside_file = outside_root.join("secret.pdf"); + std::fs::write(&outside_file, b"%PDF-1.7").expect("outside file should be written"); + let linked_id = "11111111-2222-3333-4444-555555555555"; + std::os::unix::fs::symlink(&outside_file, scores_root.join(format!("{linked_id}.pdf"))) + .expect("symlink should be created"); + assert!(resolve_existing_score_pdf(&scores_root, linked_id).is_err()); + } + + let _ = std::fs::remove_dir_all(scores_root); + let _ = std::fs::remove_dir_all(outside_root); + } +} From 5dc4d7b30ad77ab2752b4d2c38b22dd1682ff5f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:15:35 +0900 Subject: [PATCH 204/448] docs(traceability): record minimal v2 module boundary --- docs/traceability/project-format-v2-playback-preference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index 0c8029b7e..d7ecd57b8 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -10,12 +10,14 @@ The Active Player has a stable source semantic (`full_mix | vocals | bass | drum - 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. - 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 fix `be4ce61f9a865229aad9b46ad27adb79b1028258` isolates historical payload/process logic in `core`, introduces `project_format` as the current version/migration boundary, and makes crate-root Project Persistence APIs write/read version 2 while delegating v1 and legacy validation to the existing strict parser. The old `lib.rs` blob is reused exactly as `core.rs`; this is a module move, not a copied persistence implementation. +- 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. @@ -44,6 +46,7 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co - **Use an arbitrary string preference** — rejected because malformed, future, or injected values would survive as if they were current domain truth. - **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. ## Effect From 770942f006c80724a5cac970d17acae6da4a9d5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:18:05 +0900 Subject: [PATCH 205/448] test(ci): require v2 project-format Windows evidence triggers --- .../tests/test_project_persistence_workflow_policy.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/analysis-engine/tests/test_project_persistence_workflow_policy.py b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py index 9c960c93b..1fd94a013 100644 --- a/services/analysis-engine/tests/test_project_persistence_workflow_policy.py +++ b/services/analysis-engine/tests/test_project_persistence_workflow_policy.py @@ -13,7 +13,10 @@ def test_windows_project_persistence_gate_tracks_contract_inputs() -> None: 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"', From 72434d1026fe0a409bf291d91ead64d8b13f7959 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:18:28 +0900 Subject: [PATCH 206/448] fix(ci): cover v2 project-format Windows evidence --- .github/workflows/project-persistence-windows.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml index 1b7af6302..4d451d3a6 100644 --- a/.github/workflows/project-persistence-windows.yml +++ b/.github/workflows/project-persistence-windows.yml @@ -8,7 +8,10 @@ on: 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" @@ -23,7 +26,10 @@ on: 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" From e4913d717e42dbc4cadf31fcfafbff5ebfd25dcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:19:11 +0900 Subject: [PATCH 207/448] docs(traceability): record v2 Windows evidence repair --- docs/traceability/project-format-v2-playback-preference.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index d7ecd57b8..dfd09316b 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -20,6 +20,7 @@ The Active Player has a stable source semantic (`full_mix | vocals | bass | drum - 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. ## Decision @@ -47,6 +48,7 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co - **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 @@ -68,7 +70,7 @@ Migration errors are bounded format/version errors. They do not need to echo pro ### 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, and the checked-in v2 golden fixture. 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 RED/fix suite covers v1 migration, legacy migration, every valid source token, unknown tokens, a realistic revocable playback URL, typed construction without runtime authority, and the checked-in v2 golden fixture. 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 From ed5dd9a05a4ceead5a48119d854d5fc06a7e0a1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:33:54 +0900 Subject: [PATCH 208/448] test(project): require typed v2 IPC preference admission --- .../project_format_v2_playback_preference.rs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/tests/project_format_v2_playback_preference.rs b/apps/desktop/core/tests/project_format_v2_playback_preference.rs index d8be45eb9..8f6b48940 100644 --- a/apps/desktop/core/tests/project_format_v2_playback_preference.rs +++ b/apps/desktop/core/tests/project_format_v2_playback_preference.rs @@ -1,6 +1,7 @@ use bandscope_desktop_core::{ - project_content_for_document, project_document_from_content, project_payload_from_content, - ProjectDocumentPayload, ProjectPreferencesPayload, SelectedPlaybackSourcePayload, + project_content_for_document, project_document_from_content, project_document_from_value, + project_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, + SelectedPlaybackSourcePayload, }; use serde_json::{json, Value}; @@ -118,3 +119,54 @@ fn document_constructor_does_not_require_a_revocable_runtime_authority() { ); 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" + ); + } +} From 7711b4f938d6dd95dbd58a31595a3a7760834bdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:35:03 +0900 Subject: [PATCH 209/448] fix(project): admit typed v2 IPC preference documents --- apps/desktop/core/src/project_format.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index bf1ad1958..628c8e23c 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -52,8 +52,8 @@ impl Default for ProjectPreferencesPayload { } /// Current typed project document after historical migration. -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ProjectDocumentPayload { /// Validated rehearsal song compatibility view. pub song: RehearsalSongPayload, @@ -73,6 +73,17 @@ 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 From 21e75199f5a325a796e728a7b5d591d4a89682a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:38:59 +0900 Subject: [PATCH 210/448] docs(traceability): record typed v2 IPC admission --- .../project-format-v2-playback-preference.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index dfd09316b..863eed03b 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -4,11 +4,14 @@ 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. @@ -21,6 +24,8 @@ The Active Player has a stable source semantic (`full_mix | vocals | bass | drum - 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 fix `7711b4f938d6dd95dbd58a31595a3a7760834bdb` makes `ProjectDocumentPayload` a strict deserializable DTO and adds `project_document_from_value`. Root document, nested preferences, selected-source enum, and the existing rehearsal-song DTO now fail closed before publication when renderer IPC carries unknown or runtime-only state. ## Decision @@ -36,7 +41,7 @@ Version 2 adds one typed top-level section: } ``` -`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 typed document API exists for the Active Player bridge to supply an explicit stable preference in the next consumer slice. +`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 also 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. @@ -44,7 +49,8 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co - **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** — rejected because malformed, future, or injected values would survive as if they were current domain truth. +- **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. - **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. @@ -52,26 +58,28 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co ## Effect -The canonical Project Persistence branch now has a typed current document with a versioned preference boundary and executable v1/legacy migration. Current Tauri song-only saves can emit a v2 document without persisting runtime media capability data. The change does not yet mean that a user-selected stem survives reopen: the #1160 consumer still has to pass the stable selection into the typed document, and reload still has to resolve it against fresh native availability. +The canonical Project Persistence branch now has a typed current document with a versioned preference boundary, executable v1/legacy migration, and a strict 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 remain untrusted local input. The new preference is admitted only after the versioned envelope crosses the native Project Persistence parser. 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. +`.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 envelope uses `deny_unknown_fields`; `selectedPlaybackSource` is a serde enum with five accepted tokens. Unknown values and a literal `bandscope-playback://...` value fail parsing. V1/legacy inputs reuse the already hardened strict song parser rather than a permissive `serde_json::Value` migration. Future versions return `Unsupported project format version: ` before their future body is interpreted as v2. +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 errors are bounded format/version 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. +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, and the checked-in v2 golden fixture. 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. +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, and rejection of extra runtime authority at the IPC document root. 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 compatibility save command still receives only `RehearsalSong`, so it writes `full_mix` until the Active Player consumer is wired to the typed document API. 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. +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. From 4f076ce7c2a03b455409a318d045f526492497f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:43:09 +0900 Subject: [PATCH 211/448] fix(project): expose typed v2 IPC admission --- apps/desktop/core/src/crate_root.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/crate_root.rs b/apps/desktop/core/src/crate_root.rs index 65d69805d..fd067ee29 100644 --- a/apps/desktop/core/src/crate_root.rs +++ b/apps/desktop/core/src/crate_root.rs @@ -12,6 +12,6 @@ mod project_format; pub use core::*; pub use project_format::{ project_content_for_document, project_content_for_payload, project_document_from_content, - project_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, - SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, + project_document_from_value, project_payload_from_content, ProjectDocumentPayload, + ProjectPreferencesPayload, SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, }; From 5320607434995b3eea43c341a04e51b9b320ccb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:45:38 +0900 Subject: [PATCH 212/448] docs(traceability): record public v2 IPC surface repair --- .../project-format-v2-playback-preference.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index 863eed03b..838ef9fd9 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -25,7 +25,8 @@ Version 2 established the durable preference, but its first compatibility surfac - 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 fix `7711b4f938d6dd95dbd58a31595a3a7760834bdb` makes `ProjectDocumentPayload` a strict deserializable DTO and adds `project_document_from_value`. Root document, nested preferences, selected-source enum, and the existing rehearsal-song DTO now fail closed before publication when renderer IPC carries unknown or runtime-only state. +- 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 @@ -41,7 +42,7 @@ Version 2 adds one typed top-level section: } ``` -`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 also 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. +`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. @@ -51,6 +52,7 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co - **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. @@ -58,7 +60,7 @@ On reopen, the stored semantic is not sufficient authority to play audio. The co ## Effect -The canonical Project Persistence branch now has a typed current document with a versioned preference boundary, executable v1/legacy migration, and a strict 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. +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. @@ -78,7 +80,7 @@ Migration and IPC-admission errors are bounded format/validation errors. They do ### 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, and rejection of extra runtime authority at the IPC document root. 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. +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 From ecc2904f55516806b51baa4bbafeef9d700b058c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:10:17 +0900 Subject: [PATCH 213/448] test(project): require selected-source IPC bridge --- .../src/lib/projectDocumentBridge.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/desktop/src/lib/projectDocumentBridge.test.ts 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"); + }); +}); From 30bfa590df61a2b031076af81010f3e5f31372ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:10:38 +0900 Subject: [PATCH 214/448] feat(project): define renderer v2 document boundary --- apps/desktop/src/lib/projectDocument.ts | 73 +++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/desktop/src/lib/projectDocument.ts diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts new file mode 100644 index 000000000..008e8ac8f --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.ts @@ -0,0 +1,73 @@ +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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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, 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 } + }); +} From 64613fbb604c4ddc6d156c84bc520dd8d40cef19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:11:19 +0900 Subject: [PATCH 215/448] feat(project): bridge v2 document through TypeScript IPC --- apps/desktop/src/lib/analysis.ts | 35 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) 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; } From 7f9d118b08038fd5473b71f0a1243136b39e04bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:13:03 +0900 Subject: [PATCH 216/448] feat(project): carry v2 document through native save and load --- apps/desktop/src-tauri/src/main.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6fe3a00ff..dd15fdb3c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -742,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() @@ -750,7 +750,7 @@ fn save_project(payload: Value) -> Result<(), String> { .save_file() .ok_or_else(|| "User cancelled".to_string())?; - let content = project_content_for_payload(&parsed)?; + let content = project_content_for_document(&parsed)?; project_persistence::recover_project_publication(&path)?; project_persistence::publish_new_project_file(&path, content.as_bytes())?; @@ -758,7 +758,7 @@ fn save_project(payload: Value) -> Result<(), String> { } #[tauri::command] -fn load_project() -> Result { +fn load_project() -> Result { let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) .pick_file() @@ -766,7 +766,7 @@ fn load_project() -> Result { project_persistence::recover_project_publication(&path)?; let content = project_persistence::read_project_file(&path)?; - project_payload_from_content(&content) + project_document_from_content(&content) } fn scores_root_for_project( @@ -854,7 +854,7 @@ fn remove_score_pdf( if !is_valid_score_id(&score_id) { return Err("Invalid score id.".to_string()); } - let scores_root = scores_root_for_project(&app, &project_id)?; + let scores_root = scores_root_for_project(&app, "projects")?; let path = match resolve_existing_score_pdf(&scores_root, &score_id) { Ok(path) => path, Err(_) => return Ok(false), From 327c83f86c1ed213a1f6a58d382715e744ab9831 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:14:52 +0900 Subject: [PATCH 217/448] fix(project): preserve score removal project scope --- apps/desktop/src-tauri/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index dd15fdb3c..4b811ab9c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -854,7 +854,7 @@ fn remove_score_pdf( if !is_valid_score_id(&score_id) { return Err("Invalid score id.".to_string()); } - let scores_root = scores_root_for_project(&app, "projects")?; + let scores_root = scores_root_for_project(&app, &project_id)?; let path = match resolve_existing_score_pdf(&scores_root, &score_id) { Ok(path) => path, Err(_) => return Ok(false), From 8149d43e48e52b8b1a90fd86b07a335ad459a7c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:15:56 +0900 Subject: [PATCH 218/448] docs(project): trace v2 IPC bridge boundary --- docs/traceability/project-v2-ipc-bridge.md | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/traceability/project-v2-ipc-bridge.md diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md new file mode 100644 index 000000000..dc5388cc2 --- /dev/null +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -0,0 +1,46 @@ +# 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. + +## 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 and runtime authority strings fail closed on both renderer and native admission. +- 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. + +## 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. + +## 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. + +## 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. + +**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, 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. 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. 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 or extra writable state; 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. If a formerly selected stem is unavailable, the mounted player must fall back to Full mix without overwriting evidence until the Project Persistence/UI transaction is defined. Packaged Windows/macOS Save/Reopen acceptance and crash/power-loss evidence remain required. + +## Effect + +The persistence format and desktop IPC now speak the same current v2 document without copying native path authority into the renderer contract. This completes the persistence bridge prerequisite; it does not complete the Active Player reopen interaction or #962 recovery/autosave scope. From 1a20bf5c9a0500dd9ff8143f492cd0b280a12849 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:16:25 +0900 Subject: [PATCH 219/448] docs(project): align format with v2 IPC bridge --- docs/engineering/local-project-format.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 254984be5..99101c561 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -43,7 +43,7 @@ The rehearsal content inside `song` is the `RehearsalSong` contract from `@bands `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 current compatibility save command still receives only a validated song payload, so it writes version 2 with the deterministic `full_mix` default. The typed Project Persistence API can already serialize an explicit stable preference. Wiring the mounted Active Player selection into that typed document and resolving it through fresh native availability on reopen are separate consumer steps and are not claimed complete by the format migration itself. +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. @@ -55,6 +55,7 @@ Checked-in compatibility evidence: - `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 @@ -125,9 +126,9 @@ When loading `.bscope` files from disk, BandScope applies these constraints: ## Current boundary and next migration slices -Version 2 establishes the first typed project preference and an executable v1 → v2 migration. 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. +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 persist its selected semantic through the Project Persistence owner, then resolve that semantic against current native source availability on reopen. 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 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. From 3db1096baa52de34baa7fea4c1638185914d22b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:39:04 +0900 Subject: [PATCH 220/448] test(project): reject prototype-bearing persistence records --- .../lib/projectDocument.plainRecord.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/desktop/src/lib/projectDocument.plainRecord.test.ts 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..fab927c3b --- /dev/null +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -0,0 +1,42 @@ +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("continues to admit ordinary JSON-shaped project documents", () => { + const song = createDemoRehearsalSong(); + expect( + parseProjectDocument({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }) + ).toEqual({ + song, + preferences: { selectedPlaybackSource: "vocals" } + }); + }); +}); From 7cc4869560155039ff1e2e10d171505885dc39e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:39:18 +0900 Subject: [PATCH 221/448] fix(project): require plain renderer persistence records --- apps/desktop/src/lib/projectDocument.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index 008e8ac8f..c96840c26 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -23,7 +23,16 @@ const SELECTED_PLAYBACK_SOURCES = new Set([ ]); function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + 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 { @@ -33,7 +42,7 @@ function hasOnlyKeys(value: Record, allowedKeys: readonly strin /** * Validate the renderer-visible project document without accepting filesystem paths, - * runtime capability URLs, generation tokens, or unknown preference fields. + * 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"])) { From 4c2eaa8a974dbba3765b0027e4f411e5c498de11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:39:47 +0900 Subject: [PATCH 222/448] docs(project): trace plain-record IPC admission hardening --- docs/traceability/project-v2-ipc-bridge.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index dc5388cc2..59055067e 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -4,43 +4,48 @@ 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 and runtime authority strings fail closed on both renderer and native admission. +- 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. ## 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. +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. +**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, 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. Song-only compatibility writes use the deterministic `full_mix` default. +**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. Existing Rust v2 fixtures/migration contracts continue to cover disk representation and legacy/v1 migration. +**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 plus 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 or extra writable state; a crafted project could return an unsupported source semantic. Both sides fail closed rather than treating those values as project truth. +**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. If a formerly selected stem is unavailable, the mounted player must fall back to Full mix without overwriting evidence until the Project Persistence/UI transaction is defined. Packaged Windows/macOS Save/Reopen acceptance and crash/power-loss evidence remain required. +**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 now speak the same current v2 document without copying native path authority into the renderer contract. This completes the persistence bridge prerequisite; it does not complete the Active Player reopen interaction or #962 recovery/autosave scope. +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. This completes the v2 document bridge prerequisite itself; it does not complete source/bootstrap restoration, Active Player reopen interaction or #962 recovery/autosave scope. From 3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:43:29 +0900 Subject: [PATCH 223/448] test(project): cover prototype admission edge cases --- .../lib/projectDocument.plainRecord.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index fab927c3b..2e921d072 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -27,6 +27,38 @@ describe("project document plain-record admission", () => { ).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( From d1d4d0e5875f461358396566f01cd3bb6f1fbcb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:44:03 +0900 Subject: [PATCH 224/448] docs(project): record prototype edge coverage --- docs/traceability/project-v2-ipc-bridge.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 59055067e..0856b7d8a 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -27,6 +27,7 @@ Commit `3db1096baa52de34baa7fea4c1638185914d22b7` adds a focused renderer admiss - `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 @@ -40,7 +41,7 @@ Persisting the opaque playback URL was rejected because its generation/session a **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 plus an ordinary JSON-shaped positive case. Existing Rust v2 fixtures/migration contracts continue to cover disk representation and legacy/v1 migration. +**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. @@ -48,4 +49,4 @@ Persisting the opaque playback URL was rejected because its generation/session a ## 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. This completes the v2 document bridge prerequisite itself; it does not complete source/bootstrap restoration, Active Player reopen interaction or #962 recovery/autosave scope. +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. From a71439d82932f671d8079c5f7c78b401679dcb6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:07:48 +0900 Subject: [PATCH 225/448] test(project): reject trapped project record access --- .../lib/projectDocument.plainRecord.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index 2e921d072..8d7bef292 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -43,6 +43,39 @@ describe("project document plain-record admission", () => { expect(() => parseProjectDocument(trappedDocument)).toThrow("Invalid project document"); }); + it("fails closed with the public contract when own-key enumeration throws", () => { + const trappedDocument = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + }, + { + ownKeys() { + throw new Error("own-key trap"); + } + } + ); + + expect(() => parseProjectDocument(trappedDocument)).toThrow("Invalid project document"); + }); + + it("rejects accessor-backed preference fields without invoking the accessor", () => { + let getterCalls = 0; + const document = { + song: createDemoRehearsalSong() + } as Record; + Object.defineProperty(document, "preferences", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("preference getter must not run"); + } + }); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + it("admits null-prototype JSON records without widening the durable field set", () => { const song = createDemoRehearsalSong(); const preferences = Object.assign(Object.create(null) as Record, { From bc8e144355353e6311425afe734dfcf8e282ccd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:08:08 +0900 Subject: [PATCH 226/448] fix(project): fail closed on trapped record access --- apps/desktop/src/lib/projectDocument.ts | 45 +++++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index c96840c26..4b9d4b1ae 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -22,6 +22,10 @@ const SELECTED_PLAYBACK_SOURCES = new Set([ "other" ]); +type OwnDataProperty = + | { ok: true; value: unknown } + | { ok: false }; + function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; @@ -36,25 +40,52 @@ function isPlainRecord(value: unknown): value is Record { } 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; + try { + const keys = Object.keys(value); + return keys.length === allowedKeys.length && keys.every((key) => allowedKeys.includes(key)); + } catch { + return false; + } +} + +function ownEnumerableDataProperty(value: Record, key: string): OwnDataProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + return { ok: false }; + } + return { ok: true, value: descriptor.value }; + } catch { + return { ok: false }; + } } /** * Validate the renderer-visible project document without accepting filesystem paths, - * runtime capability URLs, generation tokens, prototype-bearing records, or unknown preference fields. + * runtime capability URLs, generation tokens, prototype-bearing records, accessors, + * trapped record enumeration, 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"])) { + const songProperty = ownEnumerableDataProperty(value, "song"); + const preferencesProperty = ownEnumerableDataProperty(value, "preferences"); + if (!songProperty.ok || !preferencesProperty.ok || !isPlainRecord(preferencesProperty.value)) { + throw new Error("Invalid project document"); + } + + const preferences = preferencesProperty.value; + if (!hasOnlyKeys(preferences, ["selectedPlaybackSource"])) { throw new Error("Invalid project document"); } - const selectedPlaybackSource = preferences.selectedPlaybackSource; + const selectedPlaybackSourceProperty = ownEnumerableDataProperty(preferences, "selectedPlaybackSource"); + if (!selectedPlaybackSourceProperty.ok) { + throw new Error("Invalid project document"); + } + const selectedPlaybackSource = selectedPlaybackSourceProperty.value; if ( typeof selectedPlaybackSource !== "string" || !SELECTED_PLAYBACK_SOURCES.has(selectedPlaybackSource as SelectedPlaybackSource) @@ -63,7 +94,7 @@ export function parseProjectDocument(value: unknown): ProjectDocument { } return { - song: parseRehearsalSong(value.song), + song: parseRehearsalSong(songProperty.value), preferences: { selectedPlaybackSource: selectedPlaybackSource as SelectedPlaybackSource } From eba9669b6f65f8f6d6b335fc01deb5b147d4997c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:08:53 +0900 Subject: [PATCH 227/448] docs(project): trace trapped renderer record hardening --- docs/traceability/project-v2-ipc-bridge.md | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 0856b7d8a..abcf43de8 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -6,12 +6,14 @@ Project Persistence already owned a strict `projectFormatVersion: 2` document an 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. +A further edge review found that a Proxy could still throw from own-key enumeration after passing prototype admission, and an accessor-backed top-level field could execute application-controlled code when the adapter read `preferences`. Those shapes cannot originate from parsed JSON and have no durable `.bscope` meaning. Letting their traps escape also replaced the adapter's stable `Invalid project document` contract with attacker-controlled exceptions. + ## 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. +- Unknown root/preference fields, prototype-bearing renderer records, accessor-backed project fields, trapped record enumeration 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 @@ -20,6 +22,8 @@ Commit `ecc2904f55516806b51baa4bbafeef9d700b058c` adds a renderer bridge contrac 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. +Commit `a71439d82932f671d8079c5f7c78b401679dcb6b` extends that regression boundary to two realistic hostile JavaScript-object cases before native serialization: a Proxy whose `ownKeys` trap throws and an enumerable `preferences` accessor that throws if invoked. The predecessor adapter leaked the trap/getter exceptions instead of returning its fail-closed project-document error, and it invoked the accessor once. + ## 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. @@ -28,25 +32,28 @@ Commit `3db1096baa52de34baa7fea4c1638185914d22b7` adds a focused renderer admiss - 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. +- `bc8e144355353e6311425afe734dfcf8e282ccd5` makes exact-key enumeration exception-safe and reads the outer `song`/`preferences` and nested `selectedPlaybackSource` only through own enumerable data-property descriptors. Accessor-backed fields and descriptor traps fail closed without invoking application getters; the durable JSON field set and five-value source domain are unchanged. ## 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. +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, Proxies or accessor-bearing records as equivalent to parsed JSON was rejected because executable object behavior has no durable `.bscope` semantics and expands 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. +**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, accessor-backed or trap-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. +**Mitigations.** Exact-key checks are exception-safe and operate on one enumerated key snapshot. Plain-record prototype checks reject custom prototypes. Required outer/preference values are read only from own enumerable data-property descriptors, so getters are never used as project data and descriptor failures fail closed. 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. 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, fail-closed own-key enumeration, non-invocation of an accessor-backed required field, 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. -**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, custom-prototype object, throwing Proxy or accessor-backed record in place of the declared JSON record; a crafted project could return an unsupported source semantic. Both renderer and native boundaries fail closed rather than treating those values as project truth. -**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. +**Logging/privacy.** The repair does not log rejected object contents, trap messages, local paths or project payloads. The public error remains the bounded `Invalid project document` contract rather than forwarding attacker-controlled JavaScript exception text. -**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. +**Remaining risk.** `parseRehearsalSong` remains the shared song-domain admission owner; this slice does not duplicate its nested-field policy in Project Persistence. #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. +The persistence format and desktop IPC speak the same current v2 document without copying native path authority into the renderer contract. Renderer-side admission now matches a passive JSON-record boundary not only by prototype but also by enumeration/property semantics: trap-bearing and accessor-bearing required fields are rejected without executing getters, while ordinary and intentional null-prototype JSON records remain supported. This completes the v2 document bridge hardening slice itself; it does not complete source/bootstrap restoration, Active Player reopen interaction or #962 recovery/autosave scope. From bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:10:23 +0900 Subject: [PATCH 228/448] test(project): cover nested accessor rejection --- .../lib/projectDocument.plainRecord.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index 8d7bef292..df442db8f 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -76,6 +76,26 @@ describe("project document plain-record admission", () => { expect(getterCalls).toBe(0); }); + it("rejects accessor-backed selected-source fields without invoking the accessor", () => { + let getterCalls = 0; + const preferences = {} as Record; + Object.defineProperty(preferences, "selectedPlaybackSource", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("selected source getter must not run"); + } + }); + + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences + }) + ).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + it("admits null-prototype JSON records without widening the durable field set", () => { const song = createDemoRehearsalSong(); const preferences = Object.assign(Object.create(null) as Record, { From 17777052ae55ba72aa80ba74bfce10c0278e25c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:10:56 +0900 Subject: [PATCH 229/448] docs(project): record nested accessor edge coverage --- docs/traceability/project-v2-ipc-bridge.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index abcf43de8..1f23efc78 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -33,6 +33,7 @@ Commit `a71439d82932f671d8079c5f7c78b401679dcb6b` extends that regression bounda - `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. - `bc8e144355353e6311425afe734dfcf8e282ccd5` makes exact-key enumeration exception-safe and reads the outer `song`/`preferences` and nested `selectedPlaybackSource` only through own enumerable data-property descriptors. Accessor-backed fields and descriptor traps fail closed without invoking application getters; the durable JSON field set and five-value source domain are unchanged. +- `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` adds the corresponding nested `selectedPlaybackSource` accessor regression, proving the descriptor-only boundary does not merely protect the outer `preferences` property. ## Alternatives rejected @@ -46,7 +47,7 @@ Persisting the opaque playback URL was rejected because its generation/session a **Mitigations.** Exact-key checks are exception-safe and operate on one enumerated key snapshot. Plain-record prototype checks reject custom prototypes. Required outer/preference values are read only from own enumerable data-property descriptors, so getters are never used as project data and descriptor failures fail closed. 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. 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, fail-closed own-key enumeration, non-invocation of an accessor-backed required field, 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. +**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, fail-closed own-key enumeration, non-invocation of accessor-backed outer and nested required fields, 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, custom-prototype object, throwing Proxy or accessor-backed record in place of the declared JSON record; a crafted project could return an unsupported source semantic. Both renderer and native boundaries fail closed rather than treating those values as project truth. From 3191f3865a78cf7a19babe3e611a3d07903787de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:13:48 +0900 Subject: [PATCH 230/448] test(project): require durable app-owned audio source reference --- .../project_format_v3_source_reference.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/desktop/core/tests/project_format_v3_source_reference.rs diff --git a/apps/desktop/core/tests/project_format_v3_source_reference.rs b/apps/desktop/core/tests/project_format_v3_source_reference.rs new file mode 100644 index 000000000..0f6fdc7ce --- /dev/null +++ b/apps/desktop/core/tests/project_format_v3_source_reference.rs @@ -0,0 +1,119 @@ +use bandscope_desktop_core::{ + project_content_for_document, project_document_from_content, ProjectSourceReferencePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +use serde_json::{json, Value}; + +fn v2_song() -> Value { + let fixture: Value = serde_json::from_str(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should remain valid JSON"); + fixture["song"].clone() +} + +#[test] +fn current_project_round_trips_an_app_owned_source_reference_without_a_filesystem_path() { + let content = json!({ + "projectFormatVersion": 3, + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "drums" }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + } + }) + .to_string(); + + let document = project_document_from_content(&content) + .expect("the current project should admit one app-owned source reference"); + assert_eq!( + document.source_reference, + Some(ProjectSourceReferencePayload { + project_id: "project-400-4".to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: 4096, + }) + ); + + let serialized = project_content_for_document(&document) + .expect("the admitted current project should serialize"); + let value: Value = serde_json::from_str(&serialized) + .expect("the serialized current project should remain valid JSON"); + assert_eq!(value["projectFormatVersion"], json!(CURRENT_PROJECT_FORMAT_VERSION)); + assert_eq!(value["sourceReference"]["projectId"], json!("project-400-4")); + assert_eq!(value["sourceReference"]["artifactName"], json!("source.wav")); + assert!(serialized.find("sourcePath").is_none()); + assert!(serialized.find("bandscope-playback://").is_none()); +} + +#[test] +fn v2_migrates_without_inventing_a_source_reference() { + let document = project_document_from_content(include_str!("../testdata/project-v2.json")) + .expect("v2 should migrate into the current document"); + assert_eq!(document.source_reference, None); + + let serialized = project_content_for_document(&document) + .expect("migrated v2 should serialize as the current format"); + let value: Value = serde_json::from_str(&serialized) + .expect("the migrated project should remain valid JSON"); + assert_eq!(value["projectFormatVersion"], json!(CURRENT_PROJECT_FORMAT_VERSION)); + assert!(value.get("sourceReference").is_none()); +} + +#[test] +fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { + for source_reference in [ + json!({ + "projectId": "../escape", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "../source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.mp3", + "extension": "wav", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "exe", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 0 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "sourcePath": "/Users/example/Music/private.wav" + }), + ] { + let content = json!({ + "projectFormatVersion": 3, + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "full_mix" }, + "sourceReference": source_reference + }) + .to_string(); + + assert!( + project_document_from_content(&content).is_err(), + "unsafe source reference must fail closed" + ); + } +} From 7e315daec207c1b09ea018353abaa1c34955d7b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:14:20 +0900 Subject: [PATCH 231/448] feat(project): add versioned app-owned source reference --- apps/desktop/core/src/project_format.rs | 144 ++++++++++++++++++++---- 1 file changed, 119 insertions(+), 25 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index 628c8e23c..9de0777e5 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -1,18 +1,20 @@ //! 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. +//! Version 2 introduced durable project preferences without serializing a +//! revocable runtime playback URL. Version 3 adds an app-owned audio source +//! reference that contains no user filesystem path. The existing v1/legacy +//! song parser remains the migration authority for historical inputs; this +//! module owns the current envelope presented to external crate consumers. use crate::core::{ - project_payload_from_content as project_v1_payload_from_content, RehearsalSongPayload, + is_valid_project_id, project_payload_from_content as project_v1_payload_from_content, + RehearsalSongPayload, AUDIO_EXTENSIONS, }; 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; +pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 3; /// Stable playback-source identity stored in project preferences. /// @@ -51,6 +53,26 @@ impl Default for ProjectPreferencesPayload { } } +/// Durable handle for the app-owned full-mix artifact needed after process +/// restart. +/// +/// The reference deliberately stores no absolute/relative user path. Native +/// Resource Admission derives the artifact location from `project_id` and the +/// fixed `source.` artifact name, then re-validates the byte length +/// before issuing any fresh runtime authority. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectSourceReferencePayload { + /// Opaque app-owned project namespace identifier. + pub project_id: String, + /// Fixed app-owned artifact basename, for example `source.wav`. + pub artifact_name: String, + /// Closed audio extension admitted by BandScope. + pub extension: String, + /// Expected non-zero byte length used as bounded re-admission evidence. + pub file_size_bytes: u64, +} + /// Current typed project document after historical migration. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -59,6 +81,10 @@ pub struct ProjectDocumentPayload { pub song: RehearsalSongPayload, /// Durable project preferences that are safe to persist. pub preferences: ProjectPreferencesPayload, + /// Optional app-owned source reference. Historical projects migrate with + /// this absent rather than inventing source authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_reference: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -69,27 +95,65 @@ struct ProjectFileV2Payload { preferences: ProjectPreferencesPayload, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProjectFileV3Payload { + project_format_version: u16, + song: RehearsalSongPayload, + preferences: ProjectPreferencesPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_reference: Option, +} + fn unsupported_version(version: u64) -> String { format!("Unsupported project format version: {version}") } +fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool { + if !is_valid_project_id(&reference.project_id) + || reference.file_size_bytes == 0 + || !AUDIO_EXTENSIONS.contains(&reference.extension.as_str()) + { + return false; + } + + let expected_artifact_name = format!("source.{}", reference.extension); + reference.artifact_name == expected_artifact_name +} + +fn validate_document(document: ProjectDocumentPayload) -> Result { + if document + .source_reference + .as_ref() + .is_some_and(|reference| !source_reference_is_valid(reference)) + { + return Err("Invalid project document payload".to_string()); + } + Ok(document) +} + /// Admit a renderer-supplied current project document before publication. /// /// Security Notes: renderer IPC values are untrusted. The document, nested -/// preferences, stable playback-source enum, and rehearsal-song DTO all use -/// typed allowlists/`deny_unknown_fields`; revocable playback URLs and unknown -/// runtime state therefore fail closed before any filesystem mutation. +/// preferences, stable playback-source enum, source reference, and rehearsal- +/// song DTO all use typed allowlists/`deny_unknown_fields`. Source references +/// admit only a valid project id, a fixed app-owned artifact basename, a closed +/// audio extension, and a non-zero byte length; filesystem paths and revocable +/// playback URLs 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()) + let document = serde_json::from_value::(value) + .map_err(|_| "Invalid project document payload".to_string())?; + validate_document(document) } -/// Parse a current, v1, or legacy project into the current typed document. +/// Parse a current, v2, 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 +/// Security Notes: `.bscope` bytes are untrusted input. Versions 2 and 3 use +/// `deny_unknown_fields` envelopes and closed playback-source semantics. +/// Version 3 additionally validates the app-owned source reference without +/// accepting any user filesystem path. Version 1 and legacy raw-song inputs +/// are delegated to the existing strict parser and migrated in memory with the +/// explicit `full_mix` default and no invented source reference. Unsupported /// versions fail before their body is interpreted as current truth. pub fn project_document_from_content(content: &str) -> Result { let root = serde_json::from_str::(content) @@ -100,6 +164,7 @@ pub fn project_document_from_content(content: &str) -> Result Result { let envelope = serde_json::from_value::(root) .map_err(|_| "Invalid project file format".to_string())?; - if envelope.project_format_version != CURRENT_PROJECT_FORMAT_VERSION { + if envelope.project_format_version != 2 { return Err(unsupported_version(u64::from( envelope.project_format_version, ))); @@ -126,39 +192,67 @@ pub fn project_document_from_content(content: &str) -> Result { + let envelope = serde_json::from_value::(root) + .map_err(|_| "Invalid project file format".to_string())?; + if envelope.project_format_version != CURRENT_PROJECT_FORMAT_VERSION { + return Err(unsupported_version(u64::from( + envelope.project_format_version, + ))); + } + validate_document(ProjectDocumentPayload { + song: envelope.song, + preferences: envelope.preferences, + source_reference: envelope.source_reference, + }) + .map_err(|_| "Invalid project file format".to_string()) + } _ => Err(unsupported_version(version)), } } /// Compatibility view for callers that currently consume only the song. /// -/// The current reader still accepts v1 and legacy projects through the ordered -/// migration above, while v2 preferences remain available through -/// `project_document_from_content` for the Project Persistence/UI bridge. +/// The current reader still accepts v1, v2, and legacy projects through the +/// ordered migration above, while current preferences/source references remain +/// available through `project_document_from_content` for the Project +/// Persistence/UI bridge. pub fn project_payload_from_content(content: &str) -> Result { project_document_from_content(content).map(|document| document.song) } -/// Serialize a typed current document as a strict version-2 project envelope. +/// Serialize a typed current document as a strict version-3 project envelope. pub fn project_content_for_document(payload: &ProjectDocumentPayload) -> Result { - serde_json::to_string_pretty(&ProjectFileV2Payload { + if payload + .source_reference + .as_ref() + .is_some_and(|reference| !source_reference_is_valid(reference)) + { + return Err("Invalid project document payload".to_string()); + } + + serde_json::to_string_pretty(&ProjectFileV3Payload { project_format_version: CURRENT_PROJECT_FORMAT_VERSION, song: payload.song.clone(), preferences: payload.preferences.clone(), + source_reference: payload.source_reference.clone(), }) .map_err(|_| "Failed to serialize project file format".to_string()) } /// Compatibility writer for callers that currently submit only a song. /// -/// Existing Tauri save callers therefore advance to v2 without inventing a -/// source choice: their deterministic migration default is `full_mix` until -/// the Active Player bridge supplies an explicit stable preference. +/// Existing Tauri save callers therefore advance to v3 without inventing a +/// source choice or source reference: their deterministic migration default is +/// `full_mix` and an absent source reference until Resource Admission supplies +/// an explicit app-owned artifact identity. pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> Result { project_content_for_document(&ProjectDocumentPayload { song: payload.clone(), preferences: ProjectPreferencesPayload::default(), + source_reference: None, }) } From 90ae48e1911113cc82c5ae99a4b0f1717a0ed075 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:14:32 +0900 Subject: [PATCH 232/448] fix(project): export source reference contract --- apps/desktop/core/src/crate_root.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/crate_root.rs b/apps/desktop/core/src/crate_root.rs index fd067ee29..a75ef511b 100644 --- a/apps/desktop/core/src/crate_root.rs +++ b/apps/desktop/core/src/crate_root.rs @@ -13,5 +13,6 @@ 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, + ProjectPreferencesPayload, ProjectSourceReferencePayload, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, }; From 6acd761f8a25b904352b2ae4eebcbc4f61ec5a48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:15:05 +0900 Subject: [PATCH 233/448] test(project): require source reference across renderer bridge --- .../src/lib/projectDocumentBridge.test.ts | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/projectDocumentBridge.test.ts b/apps/desktop/src/lib/projectDocumentBridge.test.ts index 1dbb6961e..87287f380 100644 --- a/apps/desktop/src/lib/projectDocumentBridge.test.ts +++ b/apps/desktop/src/lib/projectDocumentBridge.test.ts @@ -47,16 +47,59 @@ describe("project document bridge", () => { } ); + it("persists only an app-owned source reference and never a user filesystem path", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const song = createDemoRehearsalSong(); + + await saveProjectDocument({ + song, + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } + }); + + expect(invoke).toHaveBeenCalledWith("save_project", { + payload: { + song, + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } + } + }); + expect(JSON.stringify(invoke.mock.calls)).not.toContain("sourcePath"); + }); + it("returns the persisted source semantic with the reopened song", async () => { const song = createDemoRehearsalSong(); tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ song, - preferences: { selectedPlaybackSource: "vocals" } + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.flac", + extension: "flac", + fileSizeBytes: 8192 + } }); await expect(loadProjectDocument()).resolves.toEqual({ song, - preferences: { selectedPlaybackSource: "vocals" } + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.flac", + extension: "flac", + fileSizeBytes: 8192 + } }); }); @@ -72,6 +115,45 @@ describe("project document bridge", () => { await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); }); + it("rejects user paths and mismatched app-owned artifact names in source references", async () => { + const song = createDemoRehearsalSong(); + for (const sourceReference of [ + { + projectId: "../escape", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + }, + { + projectId: "project-400-4", + artifactName: "../source.wav", + extension: "wav", + fileSizeBytes: 4096 + }, + { + projectId: "project-400-4", + artifactName: "source.mp3", + extension: "wav", + fileSizeBytes: 4096 + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + sourcePath: "/Users/example/Music/private.wav" + } + ]) { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + song, + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference + }); + + await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); + } + }); + it("rejects unknown preference fields instead of creating a second writable project contract", async () => { const song = createDemoRehearsalSong(); tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ From f54be004887c11cd7a00065b7db86510e5c83ee8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:15:37 +0900 Subject: [PATCH 234/448] feat(project): validate renderer source reference contract --- apps/desktop/src/lib/projectDocument.ts | 105 ++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index 4b9d4b1ae..14460d6e5 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -8,10 +8,19 @@ export type ProjectPreferences = { selectedPlaybackSource: SelectedPlaybackSource; }; +/** App-owned audio artifact identity used for process-restart re-admission. */ +export type ProjectSourceReference = { + projectId: string; + artifactName: string; + extension: "wav" | "mp3" | "flac" | "m4a"; + fileSizeBytes: number; +}; + /** Current renderer-facing project document admitted by the native persistence owner. */ export type ProjectDocument = { song: RehearsalSong; preferences: ProjectPreferences; + sourceReference?: ProjectSourceReference; }; const SELECTED_PLAYBACK_SOURCES = new Set([ @@ -21,6 +30,13 @@ const SELECTED_PLAYBACK_SOURCES = new Set([ "drums", "other" ]); +const PROJECT_SOURCE_EXTENSIONS = new Set([ + "wav", + "mp3", + "flac", + "m4a" +]); +const PROJECT_ID_PATTERN = /^project-\d+-\d+$/; type OwnDataProperty = | { ok: true; value: unknown } @@ -48,6 +64,22 @@ function hasOnlyKeys(value: Record, allowedKeys: readonly strin } } +function hasRequiredAndOptionalKeys( + value: Record, + requiredKeys: readonly string[], + optionalKeys: readonly string[] +): boolean { + try { + const keys = Object.keys(value); + return ( + requiredKeys.every((key) => keys.includes(key)) && + keys.every((key) => requiredKeys.includes(key) || optionalKeys.includes(key)) + ); + } catch { + return false; + } +} + function ownEnumerableDataProperty(value: Record, key: string): OwnDataProperty { try { const descriptor = Object.getOwnPropertyDescriptor(value, key); @@ -60,13 +92,63 @@ function ownEnumerableDataProperty(value: Record, key: string): } } +function parseProjectSourceReference(value: unknown): ProjectSourceReference { + if ( + !isPlainRecord(value) || + !hasOnlyKeys(value, ["projectId", "artifactName", "extension", "fileSizeBytes"]) + ) { + throw new Error("Invalid project document"); + } + + const projectIdProperty = ownEnumerableDataProperty(value, "projectId"); + const artifactNameProperty = ownEnumerableDataProperty(value, "artifactName"); + const extensionProperty = ownEnumerableDataProperty(value, "extension"); + const fileSizeBytesProperty = ownEnumerableDataProperty(value, "fileSizeBytes"); + if ( + !projectIdProperty.ok || + !artifactNameProperty.ok || + !extensionProperty.ok || + !fileSizeBytesProperty.ok + ) { + throw new Error("Invalid project document"); + } + + const projectId = projectIdProperty.value; + const artifactName = artifactNameProperty.value; + const extension = extensionProperty.value; + const fileSizeBytes = fileSizeBytesProperty.value; + if ( + typeof projectId !== "string" || + !PROJECT_ID_PATTERN.test(projectId) || + typeof extension !== "string" || + !PROJECT_SOURCE_EXTENSIONS.has(extension as ProjectSourceReference["extension"]) || + typeof artifactName !== "string" || + artifactName !== `source.${extension}` || + typeof fileSizeBytes !== "number" || + !Number.isSafeInteger(fileSizeBytes) || + fileSizeBytes <= 0 + ) { + throw new Error("Invalid project document"); + } + + return { + projectId, + artifactName, + extension: extension as ProjectSourceReference["extension"], + fileSizeBytes + }; +} + /** * Validate the renderer-visible project document without accepting filesystem paths, * runtime capability URLs, generation tokens, prototype-bearing records, accessors, - * trapped record enumeration, or unknown preference fields. + * trapped record enumeration, or unknown preference/source-reference fields. */ export function parseProjectDocument(value: unknown): ProjectDocument { - if (!isPlainRecord(value) || !hasOnlyKeys(value, ["song", "preferences"])) { + if ( + !isPlainRecord(value) || + !hasRequiredAndOptionalKeys(value, ["song", "preferences"], ["sourceReference"]) + ) { throw new Error("Invalid project document"); } @@ -93,21 +175,34 @@ export function parseProjectDocument(value: unknown): ProjectDocument { throw new Error("Invalid project document"); } + let sourceReference: ProjectSourceReference | undefined; + const sourceReferenceDescriptor = Object.getOwnPropertyDescriptor(value, "sourceReference"); + if (sourceReferenceDescriptor !== undefined) { + const sourceReferenceProperty = ownEnumerableDataProperty(value, "sourceReference"); + if (!sourceReferenceProperty.ok) { + throw new Error("Invalid project document"); + } + sourceReference = parseProjectSourceReference(sourceReferenceProperty.value); + } + return { song: parseRehearsalSong(songProperty.value), preferences: { selectedPlaybackSource: selectedPlaybackSource as SelectedPlaybackSource - } + }, + ...(sourceReference ? { sourceReference } : {}) }; } /** Build the exact current renderer document before crossing the native persistence boundary. */ export function createProjectDocument( song: RehearsalSong, - selectedPlaybackSource: SelectedPlaybackSource = "full_mix" + selectedPlaybackSource: SelectedPlaybackSource = "full_mix", + sourceReference?: ProjectSourceReference ): ProjectDocument { return parseProjectDocument({ song: parseRehearsalSong(song), - preferences: { selectedPlaybackSource } + preferences: { selectedPlaybackSource }, + ...(sourceReference ? { sourceReference } : {}) }); } From 04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:16:17 +0900 Subject: [PATCH 235/448] fix(project): keep optional source admission trap-safe --- apps/desktop/src/lib/projectDocument.ts | 36 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index 14460d6e5..be4e924da 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -41,6 +41,10 @@ const PROJECT_ID_PATTERN = /^project-\d+-\d+$/; type OwnDataProperty = | { ok: true; value: unknown } | { ok: false }; +type OptionalOwnDataProperty = + | { ok: true; present: false } + | { ok: true; present: true; value: unknown } + | { ok: false; present: false }; function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -92,6 +96,24 @@ function ownEnumerableDataProperty(value: Record, key: string): } } +function optionalOwnEnumerableDataProperty( + value: Record, + key: string +): OptionalOwnDataProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { ok: true, present: false }; + } + if (!descriptor.enumerable || !("value" in descriptor)) { + return { ok: false, present: false }; + } + return { ok: true, present: true, value: descriptor.value }; + } catch { + return { ok: false, present: false }; + } +} + function parseProjectSourceReference(value: unknown): ProjectSourceReference { if ( !isPlainRecord(value) || @@ -175,15 +197,13 @@ export function parseProjectDocument(value: unknown): ProjectDocument { throw new Error("Invalid project document"); } - let sourceReference: ProjectSourceReference | undefined; - const sourceReferenceDescriptor = Object.getOwnPropertyDescriptor(value, "sourceReference"); - if (sourceReferenceDescriptor !== undefined) { - const sourceReferenceProperty = ownEnumerableDataProperty(value, "sourceReference"); - if (!sourceReferenceProperty.ok) { - throw new Error("Invalid project document"); - } - sourceReference = parseProjectSourceReference(sourceReferenceProperty.value); + const sourceReferenceProperty = optionalOwnEnumerableDataProperty(value, "sourceReference"); + if (!sourceReferenceProperty.ok) { + throw new Error("Invalid project document"); } + const sourceReference = sourceReferenceProperty.present + ? parseProjectSourceReference(sourceReferenceProperty.value) + : undefined; return { song: parseRehearsalSong(songProperty.value), From c1cdcd036749a0a9231682db9446e5fbbe410d40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:16:44 +0900 Subject: [PATCH 236/448] test(project): keep source-reference admission passive --- .../lib/projectDocument.plainRecord.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index df442db8f..51e5ccd8b 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -96,6 +96,49 @@ describe("project document plain-record admission", () => { expect(getterCalls).toBe(0); }); + it("rejects an accessor-backed source reference without invoking the accessor", () => { + let getterCalls = 0; + const document = { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" } + } as Record; + Object.defineProperty(document, "sourceReference", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("source reference getter must not run"); + } + }); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + + it("fails closed when optional source-reference descriptor inspection throws", () => { + const document = new Proxy( + { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } + }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "sourceReference") { + throw new Error("source reference descriptor trap"); + } + return Reflect.getOwnPropertyDescriptor(target, property); + } + } + ); + + expect(() => parseProjectDocument(document)).toThrow("Invalid project document"); + }); + it("admits null-prototype JSON records without widening the durable field set", () => { const song = createDemoRehearsalSong(); const preferences = Object.assign(Object.create(null) as Record, { From 5203c2846dd2d12a02ad54204e9c6b5197d1177f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:17:16 +0900 Subject: [PATCH 237/448] docs(project): document version 3 source reference boundary --- docs/engineering/local-project-format.md | 50 +++++++++++++++--------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 99101c561..e69deb2f1 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,22 +1,22 @@ # Local Project Format -This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, durable rehearsal preferences, and recovery. +This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, durable rehearsal preferences, source re-admission, and recovery. ## Overview -BandScope projects are saved as `.bscope` files. 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. +BandScope projects are saved as `.bscope` files. Current writes use a strict JSON envelope with `projectFormatVersion: 3`. The nested `song` remains the compatibility view used by the desktop rehearsal contract, `preferences` stores durable rehearsal UI intent, and the optional `sourceReference` is the first typed handle for locating an app-owned full-mix artifact after process restart. -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. +Version 2, version 1, and older raw `RehearsalSong` JSON remain supported inputs. Version 2 is migrated with its existing `preferences` and no invented source reference. Version 1 and legacy song JSON are migrated with `preferences.selectedPlaybackSource = "full_mix"` and no source reference. A migration does not infer a source artifact that the historical file never recorded. ## Schema The rehearsal content inside `song` is the `RehearsalSong` contract from `@bandscope/shared-types`. -### Top-Level Structure (version 2) +### Top-Level Structure (version 3) ```json { - "projectFormatVersion": 2, + "projectFormatVersion": 3, "song": { "id": "string", "title": "string", @@ -37,27 +37,37 @@ The rehearsal content inside `song` is the `RehearsalSong` contract from `@bands }, "preferences": { "selectedPlaybackSource": "full_mix" + }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 } } ``` `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. +`sourceReference` is optional because historical projects and compatibility callers do not have enough evidence to invent one. When present, it is restricted to an opaque BandScope `projectId`, the fixed app-owned artifact name `source.`, one of `wav | mp3 | flac | m4a`, and a non-zero byte length. It contains no source path. The current source-reference schema is a prerequisite for process-restart re-admission; it does not by itself prove that Resource Admission has already materialized or reopened the corresponding artifact. + +The native `save_project`/`load_project` commands admit and return the complete typed current document, and the TypeScript Project Persistence adapter exposes `saveProjectDocument`/`loadProjectDocument` with the same closed preference/source-reference domains. Existing song-only `saveProject`/`loadProject` callers remain compatibility adapters and do not invent a source reference. The mounted Active Player still has to compose its selected semantic and current source reference into this bridge, then resolve the reopened semantic through freshly re-admitted native source availability. `tempo` and `collaboration` are optional song fields. The native persistence boundary preserves the current shared collaboration contract and its assignment/comment/approval state domains. Role records also preserve optional `harmonicExplanation`, `transpositionPlan`, `transcription`, and integer `practiceProgress` from 0 through 100. These fields are typed project data; unknown fields still fail closed rather than being retained in an untyped JSON bag. -The project format version is independent of the application package version. Version 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. +The project format version is independent of the application package version. Version 3 rejects unknown envelope fields, invalid preference tokens, user-path-shaped source reference fields, mismatched artifact names/extensions, invalid project ids, and zero-length source evidence. 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. +- `apps/desktop/core/testdata/project-v2.json` — supported version-2 document with an explicit `vocals` preference. +- `apps/desktop/core/tests/project_format_v2_playback_preference.rs` — legacy/v1 migration and closed preference-domain contracts. +- `apps/desktop/core/tests/project_format_v2_fixture.rs` — version-2 fixture migration and current serialization. +- `apps/desktop/core/tests/project_format_v3_source_reference.rs` — current source-reference round trip, v2 migration, and fail-closed path/shape tests. +- `apps/desktop/src/lib/projectDocumentBridge.test.ts` — renderer/native bridge contract for stable source semantics and source-reference admission. +- `apps/desktop/src/lib/projectDocument.plainRecord.test.ts` — passive JSON-record admission, including accessor/proxy rejection without executing getters. -### Version 1 compatibility +### Historical migration Version 1 had the shape below and did not contain project-level preferences: @@ -68,7 +78,7 @@ Version 1 had the shape below and did not contain project-level preferences: } ``` -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. +Version 2 added only the typed preferences section. The ordered v1 → v2 migration created `preferences.selectedPlaybackSource = "full_mix"`; legacy raw-song input followed the same rule. Version 3 retains that preference and adds no source reference unless one is explicitly supplied by the current Resource Admission/Project Persistence contract. Serializing any supported predecessor writes the current version-3 envelope, so reopening the result does not rerun heuristic inference. ### Sections and Roles @@ -120,18 +130,20 @@ BandScope records user corrections in the `manualOverrides` array on a `Rehearsa 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. +2. **Strict schema validation** — current and historical envelopes plus the rehearsal song contract reject unknown fields according to their published compatibility rule. Playback preference, source reference, collaboration state, provenance, cue, role, export, and progress domains are typed rather than arbitrary strings. 3. **Bounded processing** — project JSON is parsed as data only. The format contains no executable code or runtime playback URL. -4. **Runtime-authority separation** — a selected 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. +4. **Runtime-authority separation** — a selected playback source is stored only as a stable semantic. Reopening must request a fresh native authority from current resource availability rather than trusting persisted media capability data. +5. **Filesystem-authority separation** — `sourceReference` cannot carry an absolute/relative user path. Native code must derive any app-owned artifact path from the validated project id and fixed artifact basename, validate the artifact without following untrusted path input, and compare the recorded byte length before reuse. +6. **Purpose-bound metadata** — the source reference does not persist the user's original filesystem location. Its fields exist only to locate and verify BandScope-owned audio needed for rehearsal reopen. ## 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. +Version 3 establishes the durable source-reference schema and renderer/native admission contract. It does **not** complete source re-admission. Current local intake still keeps the selected source/bootstrap authority in process memory and must be changed so Resource Admission materializes the full mix under the app-owned project namespace before a valid `sourceReference` can be written. Reopen must then derive that artifact from the validated reference, verify its non-zero recorded byte length and admission rules, reconstruct a fresh bootstrap, and only afterward let Active Player resolve `selectedPlaybackSource` against current stem availability. -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 source artifact itself must not be represented by an arbitrary filesystem path in the project file. A WebView `localStorage`/session store, a serialized `bandscope-playback` URL, or a copied external absolute path would create a second authority and is not an acceptable substitute. If the durable full-mix artifact is absent or fails re-admission, the UI must report that state rather than silently presenting a stale stem selection. -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. +The remaining Project Persistence work also includes bounded autosave, known-good backup rotation, startup recovery discovery, accessible Restore / Compare / Discard UX, descriptor-bound parent authority, deterministic migration receipts/hashes, downgrade/rollback behavior, and exhaustive interruption/disk-full/power-loss fault injection. ## Extensibility -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. +Each future `.bscope` version must have an ordered deterministic migration from every supported predecessor, validate a copy before publication, retain the prior known-good artifact until the migrated document opens successfully, and add machine-verifiable fixture/evidence. Unknown fields must either be explicitly preserved by a typed schema or rejected; they must never be silently discarded. From 76e203e8e271daef09a2d92496c5f12f33f03735 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:17:48 +0900 Subject: [PATCH 238/448] docs(traceability): record v3 source-reference decision --- .../project-format-v3-source-reference.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/traceability/project-format-v3-source-reference.md diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md new file mode 100644 index 000000000..208b10c25 --- /dev/null +++ b/docs/traceability/project-format-v3-source-reference.md @@ -0,0 +1,88 @@ +# Project format v3: app-owned audio source reference + +## Problem + +Project format v2 can persist the Active Player selection semantic, but it cannot identify the admitted full-mix artifact needed after the desktop process restarts. The current mounted reopen path therefore recovers the song while clearing its bootstrap/source authority. Persisting the existing absolute `sourcePath` or a revocable `bandscope-playback` URL would make a user filesystem path or runtime capability part of durable project truth. + +## Constraints + +- Project Persistence owns the `.bscope` schema and migrations; Resource Admission owns audio admission/materialization; Active Player owns playback selection and fresh runtime authority resolution. +- Historical projects must migrate deterministically. Missing evidence must stay missing rather than being inferred. +- A durable source handle must not contain a user filesystem path, WebView storage key, generation token, or runtime playback URL. +- Renderer and file input are untrusted and must remain passive JSON data. +- The source handle has to be sufficient for a later native re-admission implementation to derive an app-owned artifact without cross-service SQL or another writable authority. + +## RED evidence + +`3191f3865a78cf7a19babe3e611a3d07903787de` added the native contract test `project_format_v3_source_reference.rs`. The predecessor could not compile or admit the required `ProjectSourceReferencePayload` because the current format was still version 2 and `ProjectDocumentPayload` had no source-reference field. + +`6acd761f8a25b904352b2ae4eebcbc4f61ec5a48` extended the renderer/native bridge test with the same source-reference shape. The predecessor TypeScript parser admitted only `song` and `preferences`, so the new current-document payload was rejected. + +## Selected design + +Version 3 adds an optional `sourceReference`: + +```json +{ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 +} +``` + +The contract accepts only: + +- the existing opaque `project--` namespace minted by BandScope; +- `artifactName` exactly equal to `source.`; +- one admitted extension: `wav`, `mp3`, `flac`, or `m4a`; +- a positive byte length. The renderer additionally requires a JavaScript safe integer so it cannot silently round persisted byte evidence. + +The field is optional because v2/v1/legacy projects cannot prove that an app-owned source artifact exists. Their ordered migration writes version 3 with no invented reference. `selectedPlaybackSource` remains independent: it is rehearsal intent, while `sourceReference` identifies only the app-owned full-mix artifact required to rebuild native availability. + +## Rejected alternatives + +**Persist the original absolute path.** Rejected because it leaks local filesystem information, becomes stale when the file moves, and gives the project document filesystem authority. + +**Persist `bandscope-playback://...`.** Rejected because the URL is a revocable runtime capability whose generation and availability are session-specific. + +**Persist the original file name and reconstruct a path heuristically.** Rejected because it retains unnecessary user metadata and is ambiguous. The fixed `source.` artifact name is both narrower and deterministic. + +**Infer a source reference while migrating v2.** Rejected because the old document carries no evidence that Resource Admission materialized an app-owned source. Fabricating one would turn a migration into a guess. + +## GREEN implementation chain + +- `7e315daec207c1b09ea018353abaa1c34955d7b0` — version 3 envelope, deterministic v2/v1/legacy migration, strict native source-reference validation, and current serialization. +- `90ae48e1911113cc82c5ae99a4b0f1717a0ed075` — exports the new source-reference contract from the GUI-independent crate root. +- `f54be004887c11cd7a00065b7db86510e5c83ee8` — renderer current-document source-reference type and validation. +- `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` — keeps optional source-reference descriptor inspection exception-safe instead of allowing proxy traps to escape the public validation contract. +- `c1cdcd036749a0a9231682db9446e5fbbe410d40` — verifies accessor/proxy-backed source-reference input is rejected without executing getters. +- `5203c2846dd2d12a02ad54204e9c6b5197d1177f` — updates the engineering format document to the code-current v3 contract and migration boundary. + +Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. + +## Security Notes + +### Attack surface and trust boundary + +`.bscope` JSON and renderer IPC values are untrusted. `sourceReference` crosses into Project Persistence as data only. It does not grant permission to open an arbitrary path. Native Resource Admission remains the only owner allowed to derive and admit the corresponding app-owned audio artifact. + +### Allowlist and validation + +Native and TypeScript boundaries reject unknown source-reference fields. Project ids use the existing BandScope minted-id grammar. Artifact names are derived from the admitted extension and cannot contain path traversal. The extension is closed to the existing audio allowlist. Byte evidence must be positive; the renderer additionally rejects unsafe integers. + +### Safe failure + +Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. A future re-admission implementation must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, or fails audio decode/admission checks. + +### Logging and privacy + +The durable reference intentionally excludes the original local path and original file name. Error reporting should continue using bounded/redacted buyer copy and must not add the derived app-owned path to renderer-visible diagnostics unless there is a separate explicit diagnostic contract. + +### Test points + +`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, and unknown `sourcePath` rejection. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. + +### Remaining risk + +Version 3 is a schema foundation, not completed source re-admission. Current Resource Admission still stores bootstrap source information in process memory and uses the selected external source path. The next causal slice must materialize the admitted full mix under the app-owned project namespace, write `sourceReference` only after that succeeds, and reconstruct a fresh bootstrap from the validated reference on reopen. Content digest/stronger bounded identity, cleanup/retention policy for app-owned audio, crash injection during materialization, and rights-cleared Windows/macOS real-audio acceptance remain required before this path can be called release-ready. From 818b9b500023d3d4c0bda65255c3089e03e023f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:18:38 +0900 Subject: [PATCH 239/448] docs(changelog): record project format v3 source reference --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3044738ab..237ccc1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. -- Write project files through the versioned `projectFormatVersion: 1` envelope and retain validated tempo values across save/load, with explicit legacy and unsupported-version handling. +- Evolve local project writes to `projectFormatVersion: 3`: retain deterministic legacy/v1/v2 migration, persist the closed Active Player source preference, and optionally store a path-free app-owned audio `sourceReference` for process-restart re-admission. ### Changed @@ -24,6 +24,7 @@ - Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path. - Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. - Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. +- Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, and unsafe byte-size values fail closed before persistence IPC. ## [0.1.3] - 2026-04-29 From ace91a29e540919d02716dd492e290f9743422a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:23:15 +0900 Subject: [PATCH 240/448] fix(project): align historical migration tests with v3 envelope --- .../project_format_v2_playback_preference.rs | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/desktop/core/tests/project_format_v2_playback_preference.rs b/apps/desktop/core/tests/project_format_v2_playback_preference.rs index 8f6b48940..54212ff6c 100644 --- a/apps/desktop/core/tests/project_format_v2_playback_preference.rs +++ b/apps/desktop/core/tests/project_format_v2_playback_preference.rs @@ -1,7 +1,7 @@ use bandscope_desktop_core::{ project_content_for_document, project_document_from_content, project_document_from_value, project_payload_from_content, ProjectDocumentPayload, ProjectPreferencesPayload, - SelectedPlaybackSourcePayload, + SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, }; use serde_json::{json, Value}; @@ -10,7 +10,7 @@ fn v1_fixture() -> &'static str { } #[test] -fn v1_migrates_to_v2_with_full_mix_as_the_explicit_default() { +fn v1_migrates_to_current_with_full_mix_as_the_explicit_default() { let document = project_document_from_content(v1_fixture()) .expect("the supported v1 fixture should migrate to the current project document"); let serialized = project_content_for_document(&document) @@ -18,15 +18,19 @@ fn v1_migrates_to_v2_with_full_mix_as_the_explicit_default() { 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["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); assert_eq!( value["preferences"]["selectedPlaybackSource"], json!("full_mix") ); + assert!(value.get("sourceReference").is_none()); } #[test] -fn v2_preserves_each_stable_playback_source_semantic() { +fn v2_preserves_each_stable_playback_source_semantic_when_migrated_to_current() { let v1: Value = serde_json::from_str(v1_fixture()).expect("v1 fixture should parse"); let song = v1["song"].clone(); @@ -43,13 +47,18 @@ fn v2_preserves_each_stable_playback_source_semantic() { 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"); + .expect("a valid v2 document should serialize as the current version"); let round_trip_value: Value = serde_json::from_str(&round_trip) - .expect("the serialized v2 document should remain valid JSON"); + .expect("the serialized current document should remain valid JSON"); + assert_eq!( + round_trip_value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); assert_eq!( round_trip_value["preferences"]["selectedPlaybackSource"], json!(selected_source) ); + assert!(round_trip_value.get("sourceReference").is_none()); } } @@ -90,11 +99,15 @@ fn legacy_song_compatibility_also_migrates_to_full_mix() { 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["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); assert_eq!( value["preferences"]["selectedPlaybackSource"], json!("full_mix") ); + assert!(value.get("sourceReference").is_none()); // Existing callers that consume only the song view must remain source-compatible. assert!(project_payload_from_content(&legacy_song).is_ok()); @@ -108,15 +121,21 @@ fn document_constructor_does_not_require_a_revocable_runtime_authority() { preferences: ProjectPreferencesPayload { selected_playback_source: SelectedPlaybackSourcePayload::Drums, }, + source_reference: None, }; let serialized = project_content_for_document(&document) .expect("typed project preferences should serialize without a playback URL"); - let value: Value = serde_json::from_str(&serialized).expect("v2 JSON should parse"); + let value: Value = serde_json::from_str(&serialized).expect("current project JSON should parse"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); assert_eq!( value["preferences"]["selectedPlaybackSource"], json!("drums") ); + assert!(value.get("sourceReference").is_none()); assert!(!serialized.contains("bandscope-playback://")); } @@ -135,8 +154,12 @@ fn ipc_document_payload_accepts_only_stable_project_preferences() { .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"); + .expect("an admitted IPC document should serialize to the current durable envelope"); + let value: Value = serde_json::from_str(&serialized).expect("current JSON should parse"); + assert_eq!( + value["projectFormatVersion"], + json!(CURRENT_PROJECT_FORMAT_VERSION) + ); assert_eq!( value["preferences"]["selectedPlaybackSource"], json!(selected_source) From c4980be4439db84ae5e903298c6d832486553a92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:24:11 +0900 Subject: [PATCH 241/448] docs(traceability): record v3 predecessor-test RCA --- docs/traceability/project-format-v3-source-reference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md index 208b10c25..00c806ee1 100644 --- a/docs/traceability/project-format-v3-source-reference.md +++ b/docs/traceability/project-format-v3-source-reference.md @@ -18,6 +18,8 @@ Project format v2 can persist the Active Player selection semantic, but it canno `6acd761f8a25b904352b2ae4eebcbc4f61ec5a48` extended the renderer/native bridge test with the same source-reference shape. The predecessor TypeScript parser admitted only `song` and `preferences`, so the new current-document payload was rejected. +A fresh post-change sweep then found a separate migration-test regression before hosted CI could be treated as evidence: `project_format_v2_playback_preference.rs` still hard-coded serialized version `2` and directly constructed `ProjectDocumentPayload` without the new optional field. That was not a product-format rollback signal; it was predecessor test code that had not been migrated with the format owner. `ace91a29e540919d02716dd492e290f9743422a8` updates those assertions to `CURRENT_PROJECT_FORMAT_VERSION`, explicitly checks that historical migrations do not invent `sourceReference`, and adds `source_reference: None` to the typed constructor. This repair preserves the v2 input compatibility contract while making current-output expectations version-aware. + ## Selected design Version 3 adds an optional `sourceReference`: @@ -58,6 +60,7 @@ The field is optional because v2/v1/legacy projects cannot prove that an app-own - `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` — keeps optional source-reference descriptor inspection exception-safe instead of allowing proxy traps to escape the public validation contract. - `c1cdcd036749a0a9231682db9446e5fbbe410d40` — verifies accessor/proxy-backed source-reference input is rejected without executing getters. - `5203c2846dd2d12a02ad54204e9c6b5197d1177f` — updates the engineering format document to the code-current v3 contract and migration boundary. +- `ace91a29e540919d02716dd492e290f9743422a8` — repairs stale v2-output expectations and typed-constructor compilation after the version advance without weakening v2 input compatibility. Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. @@ -81,7 +84,7 @@ The durable reference intentionally excludes the original local path and origina ### Test points -`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, and unknown `sourcePath` rejection. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. +`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, and unknown `sourcePath` rejection. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. ### Remaining risk From a0bb091b6b48f6c3872242cd45d9a44041188eab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:26:02 +0900 Subject: [PATCH 242/448] docs(architecture): align project persistence with v3 --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2312a8a0..e6653e295 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,7 +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. +- Current Project Persistence writes a strict `projectFormatVersion: 3` envelope around the validated rehearsal song, closed Active Player preference, and optional path-free app-owned audio `sourceReference`; legacy raw-song, v1, and v2 inputs remain readable through ordered migration, while Resource Admission still owns the unfinished app-owned full-mix materialization/re-admission needed after process restart. Source/derived/decision/handoff expansion, autosave/recovery UX, and volatile runtime authority remain follow-up work under #962 rather than parallel stores. - Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener. - Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase. - Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. @@ -127,4 +127,4 @@ Last updated: 2026-03-11 - Security docs and checks are part of the default quickcheck path so design drift is caught early. - Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. - Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. -- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path. +- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path so dependency drift is caught early. From bb4005340eb32a6cb4122aaaa6db0f8a6b5487eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:27:41 +0900 Subject: [PATCH 243/448] docs(architecture): date v3 persistence boundary --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6653e295..c8e5d152b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-09-06 ## Brand source @@ -127,4 +127,4 @@ Last updated: 2026-03-11 - Security docs and checks are part of the default quickcheck path so design drift is caught early. - Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. - Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. -- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path so dependency drift is caught early. +- Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path. From d0eb7a642ccc60ba8cf94923b529efb305e0c9f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:28:51 +0900 Subject: [PATCH 244/448] docs(traceability): mark v2 preference as historical --- .../project-format-v2-playback-preference.md | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index 838ef9fd9..ec5dc61bd 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -1,36 +1,34 @@ # Project Format v2 Playback Preference Traceability +> Historical slice. Version 2 established the durable Active Player selection semantic. Current writes are `projectFormatVersion: 3`; see `docs/traceability/project-format-v3-source-reference.md`. This document preserves the v2 decision/evidence chain and must not be read as the current on-disk contract. + ## Problem -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. +Project Persistence version 1 stored only the rehearsal `song`, while Active Player needed one durable source semantic: `full_mix | vocals | bass | drums | other`. Persisting a mounted `bandscope-playback` URL would have been incorrect because that URL is a revocable native runtime authority, not project truth. -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. +The first v2 compatibility surface also exposed only a `RehearsalSong` through Tauri, so a renderer could not yet carry an explicit stable selection through the canonical Project Persistence admission boundary. ## Constraints -- #970/#962 remains the single Project Persistence owner. #1160 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. +- #970/#962 remains the single Project Persistence owner; #1160 is an Active Player/UI consumer and must not create another localStorage/session/file writer. +- Historical v1 and legacy raw-song parsing remains strict. Missing historical selection evidence migrates deterministically to `full_mix`. +- Playback preference is a closed semantic. Native playback URLs, filesystem paths, generation tokens, and capability receipts remain outside durable preference state. +- Renderer IPC input is untrusted and must pass typed native admission before filesystem mutation. +- Unsupported future versions fail explicitly before their body is interpreted as current truth. ## RED → fix evidence -- RED `86207ea0459f1a6e27e80f571ad5d6462a0d6fab` 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. +- RED `86207ea0459f1a6e27e80f571ad5d6462a0d6fab` added `apps/desktop/core/tests/project_format_v2_playback_preference.rs`. The predecessor lacked the current-document API and typed preference. The test required deterministic v1/legacy migration to `full_mix`, round-trip preservation of all five semantics, rejection of unknown and `bandscope-playback` values, and construction without runtime authority. +- Causal implementation `be4ce61f9a865229aad9b46ad27adb79b1028258` introduced `project_format` as the then-current migration boundary while delegating historical song validation to the existing strict parser. +- Review-surface repair `e95b1db4495df5d9c721271f9b8edc54840eb004` restored the historical core source to `apps/desktop/core/src/lib.rs` and kept the new public surface in a small crate-root adapter rather than carrying a large file move. +- Golden fixture `4aa18fa8cbe5e59cf3f1e195f9a20e51c36e4da7` added `project-v2.json` with `vocals`; `73dc9a7314c0e20938fc767c207e4102e1bbf106` verified round-trip preservation. +- Evidence-trigger RED `770942f006c80724a5cac970d17acae6da4a9d5b` showed the focused Windows lane omitted the new format inputs. `72434d1026fe0a409bf291d91ead64d8b13f7959` added those paths without reducing its Rust test command. +- IPC-admission RED `ed5dd9a05a4ceead5a48119d854d5fc06a7e0a1c` required strict renderer-shaped `{ song, preferences }` admission. `7711b4f938d6dd95dbd58a31595a3a7760834bdb` implemented it and `4f076ce7c2a03b455409a318d045f526492497f6` repaired the missing public re-export. +- After the later v3 advance, fresh review found this v2 test still hard-coded serialized version `2` and instantiated `ProjectDocumentPayload` without the new optional field. `ace91a29e540919d02716dd492e290f9743422a8` made output assertions use `CURRENT_PROJECT_FORMAT_VERSION`, verified that v1/v2/legacy migration does not invent `sourceReference`, and preserved v2 as an input contract rather than current output truth. -## Decision +## Historical decision -Version 2 adds one typed top-level section: +Version 2 introduced: ```json { @@ -42,46 +40,41 @@ Version 2 adds one typed top-level section: } ``` -`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. +`selectedPlaybackSource` accepts exactly `full_mix`, `vocals`, `bass`, `drums`, or `other`. V1 and legacy inputs migrate to `full_mix` because they contain no durable evidence for a stem selection. A stored semantic never grants playback authority; reopen must resolve it against fresh native resource availability. ## Alternatives rejected -- **Persist the current `bandscope-playback` URL** — 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. +- **Persist the current `bandscope-playback` URL** — generation-bound capability is revocable runtime state. +- **Put the selection inside `song`** — it is project/UI preference, not MIR/rehearsal-song truth. +- **Use arbitrary strings or raw JSON** — malformed/future/runtime-only values would be accepted as domain truth. +- **Split song and preference admission** — one durable document would gain two inconsistent trust boundaries. +- **Infer the latest generated stem during migration** — historical artifacts contain no evidence for that claim. +- **Create a WebView persistence store** — that would create a second writer capable of disagreeing with the crash-safe project artifact. -## Effect +## Current 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. +The v2 decision survives in current v3 as the same closed `preferences.selectedPlaybackSource` domain and deterministic historical migration rule. Tauri `save_project`/`load_project` now admit/return the typed current document rather than the old song-only compatibility view, so the historical bridge gap described above has been superseded. -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. +Version 3 adds a separate optional path-free app-owned `sourceReference`. That field is deliberately not a playback authority and is not inferred for v2/v1/legacy projects. Current process-restart audio reopen is still incomplete because Resource Admission has not yet materialized the full mix under the app-owned project namespace and reconstructed a fresh bootstrap from that reference. ## 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. +`.bscope` bytes and renderer IPC values are untrusted local input. Playback preference is admitted through Project Persistence only. Native resource admission remains the authority for playback capabilities. ### Validation and fail-closed behavior -The 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. +The historical v2 envelope and current preference DTO use `deny_unknown_fields`; selection is a five-value enum; the rehearsal song remains strict typed data. Unknown root/preference values and literal `bandscope-playback://...` values fail before publication. Current v3 adds a separately typed source-reference boundary rather than weakening this preference contract. ### 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. +Preference/migration errors are bounded validation errors and need not echo project paths, song/collaboration content, media URLs, credentials, or audio metadata. The preference itself contains no locator. ### Test points -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. +`project_format_v2_playback_preference.rs` continues to cover v1/legacy migration, every valid source token, invalid/revocable tokens, typed construction without runtime authority, and renderer-shaped admission. Its current-output assertions are version-aware so v2 remains verified as a supported predecessor instead of pretending to be the current writer. ### Remaining risk -The 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. +The preference schema itself is no longer the process-restart blocker. Remaining work is owned by the v3/resource-admission path: app-owned full-mix materialization and re-admission, mounted Save/Reopen composition, fresh stem-authority resolution/fallback, stronger content identity where required, autosave/backup/startup recovery UX, migration receipts and downgrade behavior, descriptor-bound parent authority, and exhaustive interruption/power-loss evidence. Exact-head cross-platform CI and independent review remain mandatory before merge/release. From 28588c1017542c41d5a6db6bc74da2a377fe368e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:29:20 +0900 Subject: [PATCH 245/448] docs(traceability): align v2 IPC history with v3 --- docs/traceability/project-v2-ipc-bridge.md | 60 +++++++++------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 1f23efc78..12fd43b69 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -1,60 +1,48 @@ # Project v2 IPC Bridge Traceability -## Problem +> Historical bridge slice. The bridge introduced for v2 remains in use, but current Project Persistence writes version 3 and can carry an optional path-free `sourceReference`. See `project-format-v3-source-reference.md` for the current source-reference boundary. -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. +## Problem -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. +Project Persistence had a strict v2 document and durable `preferences.selectedPlaybackSource`, but the production desktop bridge initially admitted and returned only the `RehearsalSong` compatibility view. Active Player therefore lacked one typed Save/Reopen path for `full_mix | vocals | bass | drums | other` without creating another WebView store or persisting a revocable `bandscope-playback` authority. -A further edge review found that a Proxy could still throw from own-key enumeration after passing prototype admission, and an accessor-backed top-level field could execute application-controlled code when the adapter read `preferences`. Those shapes cannot originate from parsed JSON and have no durable `.bscope` meaning. Letting their traps escape also replaced the adapter's stable `Invalid project document` contract with attacker-controlled exceptions. +Later review found that renderer admission accepted custom-prototype objects, then that Proxy own-key/descriptor traps and accessor-backed fields could escape the stable validation contract or execute application-controlled getters. Those executable JavaScript shapes cannot originate from parsed JSON and have no durable `.bscope` meaning. ## 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, accessor-backed project fields, trapped record enumeration 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. +- Playback selection persists only as a stable semantic; filesystem paths, native capability URLs, generation tokens and discovery receipts remain outside preference state. +- Song-only callers remain compatibility adapters and deterministically default to `full_mix` when they do not own a selection. +- Unknown fields, prototype-bearing records, accessors, enumeration/descriptor traps and runtime-authority strings fail closed before persistence IPC; native admission repeats the typed boundary. +- The bridge does not itself make a reopened stem playable. Stored intent must be combined with freshly re-admitted native audio availability. -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. +## RED → fix evidence -Commit `a71439d82932f671d8079c5f7c78b401679dcb6b` extends that regression boundary to two realistic hostile JavaScript-object cases before native serialization: a Proxy whose `ownKeys` trap throws and an enumerable `preferences` accessor that throws if invoked. The predecessor adapter leaked the trap/getter exceptions instead of returning its fail-closed project-document error, and it invoked the accessor once. - -## 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. -- `bc8e144355353e6311425afe734dfcf8e282ccd5` makes exact-key enumeration exception-safe and reads the outer `song`/`preferences` and nested `selectedPlaybackSource` only through own enumerable data-property descriptors. Accessor-backed fields and descriptor traps fail closed without invoking application getters; the durable JSON field set and five-value source domain are unchanged. -- `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` adds the corresponding nested `selectedPlaybackSource` accessor regression, proving the descriptor-only boundary does not merely protect the outer `preferences` property. +- `ecc2904f55516806b51baa4bbafeef9d700b058c` added the renderer bridge RED for all five stable semantics, round-trip load, runtime-authority rejection and unknown preference fields. +- `30bfa590df61a2b031076af81010f3e5f31372ea` added the TypeScript Project Persistence anti-corruption boundary; `64613fbb604c4ddc6d156c84bc520dd8d40cef19` wired `saveProjectDocument`/`loadProjectDocument` through the existing Tauri command boundary. +- `7f9d118b08038fd5473b71f0a1243136b39e04bc` changed native `save_project` to strict current-document admission and `load_project` to return the typed current document. `327c83f86c1ed213a1f6a58d382715e744ab9831` immediately reverted an unrelated transient score-root edit found during review. +- `3db1096baa52de34baa7fea4c1638185914d22b7` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. +- `a71439d82932f671d8079c5f7c78b401679dcb6b` added Proxy/accessor REDs. `bc8e144355353e6311425afe734dfcf8e282ccd5` made exact-key enumeration exception-safe and required own enumerable data properties; `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` added nested selection-accessor coverage. +- The later v3 source-reference extension preserves the same passive-record boundary: `f54be004887c11cd7a00065b7db86510e5c83ee8` adds the renderer source-reference contract, `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` closes optional descriptor traps, and `c1cdcd036749a0a9231682db9446e5fbbe410d40` verifies source-reference getters/traps are not executed. ## 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, Proxies or accessor-bearing records as equivalent to parsed JSON was rejected because executable object behavior has no durable `.bscope` semantics and expands the renderer-side trust surface without buyer value. +Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. ## 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, accessor-backed or trap-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. +**Attack surface.** Renderer IPC and reopened `.bscope` JSON are untrusted. Playback capabilities and any source locator are also untrusted and must not become durable authority merely because the renderer sees them. -**Mitigations.** Exact-key checks are exception-safe and operate on one enumerated key snapshot. Plain-record prototype checks reject custom prototypes. Required outer/preference values are read only from own enumerable data-property descriptors, so getters are never used as project data and descriptor failures fail closed. 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. Song-only compatibility writes use the deterministic `full_mix` default. +**Trust boundary.** TypeScript validates the current project document before invoke and after load; Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. -**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, fail-closed own-key enumeration, non-invocation of accessor-backed outer and nested required fields, 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. +**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. -**Realistic threats.** A renderer bug or compromised WebView could attempt to persist an absolute path, stale playback capability URL, extra writable state, custom-prototype object, throwing Proxy or accessor-backed record in place of the declared JSON record; a crafted project could return an unsupported source semantic. Both renderer and native boundaries fail closed rather than treating those values as project truth. +**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, and invalid path-shaped reference fields. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. -**Logging/privacy.** The repair does not log rejected object contents, trap messages, local paths or project payloads. The public error remains the bounded `Invalid project document` contract rather than forwarding attacker-controlled JavaScript exception text. +**Logging/privacy.** Rejected object contents, trap text, local paths and project payloads are not forwarded as validation output. The public renderer error remains bounded rather than echoing attacker-controlled exceptions. -**Remaining risk.** `parseRehearsalSong` remains the shared song-domain admission owner; this slice does not duplicate its nested-field policy in Project Persistence. #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. +## Current effect and remaining risk -## Effect +The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. -The persistence format and desktop IPC speak the same current v2 document without copying native path authority into the renderer contract. Renderer-side admission now matches a passive JSON-record boundary not only by prototype but also by enumeration/property semantics: trap-bearing and accessor-bearing required fields are rejected without executing getters, while ordinary and intentional null-prototype JSON records remain supported. This completes the v2 document bridge hardening slice itself; it does not complete source/bootstrap restoration, Active Player reopen interaction or #962 recovery/autosave scope. +Process-restart playback is nevertheless still incomplete. Current Resource Admission references the externally selected absolute source path and keeps bootstrap state in memory, while mounted project load clears `jobResultBootstrap`. #970/#962 must materialize the admitted full mix under the app-owned project namespace and reconstruct a fresh bootstrap from the validated v3 source reference. #1160 can then resolve the persisted semantic against fresh stem availability and fail closed to Full mix if the preferred stem no longer exists. Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery and independent exact-head review evidence remain release gates. From 8383b3ad7a097f0410d8d41e1f234b306611fb3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:30:09 +0900 Subject: [PATCH 246/448] docs(project): align bridge comments with v3 --- apps/desktop/src/lib/analysis.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index fa0ed829f..5a1537684 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -350,13 +350,13 @@ export async function importYoutubeUrl(url: string): Promise { const parsedDocument = parseProjectDocument(projectDocument); await invokeAnalysis("save_project", { payload: parsedDocument }); } -/** Reopen one current v2 project document, including stable Active Player preferences. */ +/** Reopen one current versioned project document, including durable Project Persistence state. */ export async function loadProjectDocument(): Promise { const response = await invokeAnalysis("load_project"); return parseProjectDocument(response); @@ -370,7 +370,7 @@ export async function saveProject( await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource)); } -/** Compatibility load for existing song-only consumers while #1160 adopts the v2 document. */ +/** Compatibility load for existing song-only consumers while mounted reopen composition remains #962/#1160 work. */ export async function loadProject(): Promise { return (await loadProjectDocument()).song; } From 9e9d9ce98f5b3c1997d9c4853ba88db3634abf20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:31:29 +0900 Subject: [PATCH 247/448] docs(traceability): align shared-song contract with v3 --- ...roject-persistence-shared-song-contract.md | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index 045b6767b..95a1077b8 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -7,70 +7,69 @@ The desktop shared contract already permits collaboration data and role-level re ## 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. +- Preserve finite-positive tempo validation, strict unknown-field rejection, legacy/v1/v2 compatibility fixtures, the current versioned migration boundary, and atomic publication/recovery behavior. +- Do not serialize volatile `bandscope-playback` authorities or user filesystem paths into `.bscope` files. +- Do not replace the current file wholesale with an older #1160 snapshot because it predates later #970 persistence hardening. +- Closed-domain validation must mirror the current shared renderer contract rather than inventing persistence-only values. ## RED → fix evidence -- Structural RED: `93e9e80fa13d93692fdbd8d7d9acd10714ee8e8d` 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. +- Structural RED `93e9e80fa13d93692fdbd8d7d9acd10714ee8e8d` requires parse/serialize preservation of current collaboration and role fields. `819d8af80e425dc5627d86659a5fc97ec90c2767` adds typed native DTOs while retaining tempo/unknown-field invariants. +- `6bcdf160a7e95cc540d96e49e25868c19a438106` proves invalid collaboration sync/status tokens and `practiceProgress = 101` fail closed; `a1cf37ea98db2f8024ca710d563d879c04204961` closes those domains with enums and a 0–100 integer bound. +- `ed61d1c5f10e2baa4290fb40d692b82fb7dde500` proves explicit `null` is not omission for collaboration, collaboration `roleId`, and role explanation/transposition/transcription fields. `8b4ae848ec360a5af42b50076af15b643ae5275e` implements present-value deserialization; `ed9abedf0e5069fa93780fa3440ca91500cbdd93` extends it to optional `scoreAttachments`. +- `2b0a47e6305b7b7a3e87857335d0f36dfabc9712` adds negative closed-domain cases; `96d66ed6f5fad918b0ddef8a1e6494b76f8bafd0` replaces unrestricted strings with exact serde enums. Manual overrides use a user-only harmony payload so outer `source: "user"` cannot mask model-owned nested provenance. `f8c30150375b39d54e1775d941f6515d2686410c` exercises every valid section-form, confidence, provenance, role-type, cue-kind, rehearsal-priority, and export-format token. +- `d7886876b285f16ceda83ff5e0dd848e31cf7f97` extends the repository Security Notes verifier from plans to traceability records; `0185267ab819dd4b9ac1352f5fce1df8e2a7a782` adds the required Project Persistence security boundary. +- `a7c86be8e20895e3baebee44d33ef765e0837b5f` requires the buyer-visible limit to name the exact `5 * 1024 * 1024` ceiling as 5 MiB. `04e19ef6d19aced87e22015e4ec165cbce89f1d0` fixes the native diagnostic and `73d6a80183c19166b75be05f9286bee3769069e0` aligns the engineering format documentation without changing the byte threshold. +- Later project-format work preserves these shared-song rules while advancing current writes to version 3. `ace91a29e540919d02716dd492e290f9743422a8` repairs stale v2-output assertions so legacy/v1/v2 remain predecessor compatibility inputs rather than being mistaken for current output. + +The shared renderer authority is `packages/shared-types/src/index.ts` on protected `develop`. Relevant domains are section form `intro | verse | pre-chorus | chorus | bridge | outro | tag | pickup | stop | handoff`; confidence `low | medium | high`; provenance `model | user`; cue kind `lyric | count | transition`; role type `instrument | vocal | hand`; rehearsal priority `low | medium | high`; export format `cue-sheet | chart-summary`; manual override field `harmony` with outer and value provenance fixed to `user`; collaboration sync `local_only | planned_cloud`; assignment status `todo | in_progress | ready | blocked`; comment status `open | resolved`; approval status `pending | approved | changes_requested`; and optional integer `practiceProgress` from 0 through 100. Optional fields use omission, not explicit `null`, as the absent representation. ## Alternatives rejected -- **Copy the #1160 `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. +- **Copy the #1160 Rust snapshot:** it would overwrite later #970 persistence invariants and violate owner/consolidation boundaries. +- **Store new fields as `serde_json::Value`:** it weakens the fail-closed schema and turns compatibility into an untyped bag. +- **Keep shared closed domains as `String`:** malformed or future tokens could be persisted as current domain values. +- **Use general provenance for manual overrides:** the shared contract requires the override and its harmony value to be explicitly user-owned. +- **Clamp invalid practice progress:** silent coercion hides corruption or contract drift. +- **Treat explicit `null` as omission:** the renderer does not, so doing so natively creates cross-language disagreement. +- **Keep `5MB` for a binary ceiling:** 5 × 1024 × 1024 bytes is 5 MiB; buyer-visible diagnostics must name the actual unit. + +## Current effect -## Effects and remaining risks +A current shared rehearsal song crosses Project Persistence without dropping the covered fields. Collaboration/progress state, omission-versus-null semantics, and closed section/role/confidence/provenance/cue/export/manual-override domains are typed rather than arbitrary strings. The project ceiling remains exactly 5,242,880 bytes. -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. +Current `.bscope` writes are now `projectFormatVersion: 3`, not v1. V3 retains the closed stable playback preference and adds an optional path-free app-owned `sourceReference`; legacy raw-song, v1, and v2 inputs migrate deterministically without inventing source evidence. The source-reference schema is separate from shared-song MIR/rehearsal truth. -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. +Transcription-number semantics still require an evidence-driven cross-language contract: the shared validator currently type-checks `onset`, `offset`, and `velocity` as JavaScript numbers rather than defining rehearsal-specific numeric bounds, so persistence must not invent such bounds without product/scientific evidence. ## Security Notes ### Attack surface -`.bscope` content is untrusted local file input, 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. +`.bscope` content is untrusted local file input. Save targets, recovery journals, staged/backup/displaced files, file metadata, collaboration payloads, role-level rehearsal data, renderer project JSON, and the optional app-owned source reference cross trust boundaries. This remains local-first and 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. +Native Project Persistence is the durable storage authority. It admits the versioned envelope, applies `deny_unknown_fields`, validates finite-positive tempo and closed domains, rejects explicit `null` where omission is required, and keeps volatile playback capabilities and user paths out of durable truth. Resource Admission—not Project Persistence—owns the future derivation/re-admission of an app-owned audio artifact from a validated v3 source reference. ### 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. +Typed allowlists are used instead of arbitrary JSON/string bags. Reads are bounded to 5 MiB and use no-follow/native-identity checks. Saves stage and sync complete bytes before publication, preserve data-file permissions without executable/special bits, and use target-scoped recovery journals plus parent-directory synchronization. Current source references are path-free and limited to a BandScope project id, fixed `source.` artifact name, admitted extension, and positive byte evidence; malformed references fail before publication. ### 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. +Malformed/unsupported envelopes, invalid shared-domain tokens, explicit-null drift, unsafe paths, source-reference mismatch, identity mismatch, oversized files, and ambiguous recovery state return bounded product errors without echoing project content, local paths, collaboration text, credentials, or secret-shaped values. Failure must retain known-good data or retryable recovery state once mutation begins; it must not coerce corrupt values, fabricate source evidence, or fall back to direct overwrite. ### 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. +Executable coverage includes shared-song parse/serialize parity, closed-domain positive/negative cases, omission-versus-null behavior, progress bounds, legacy/v1/v2 migration, v3 source-reference round trip/rejection, exact 5 MiB diagnostics, symlink/reparse and ancestor checks, native file identity, first-save/no-clobber behavior, existing-target replacement, stage cleanup, permission normalization, Windows replacement/recovery, macOS/Windows case-alias recovery, completed rollback, stale-journal cleanup, and passive renderer object admission. `scripts/checks/verify_security_notes.py` treats traceability records as governed Security Notes documents. ### 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. +Relevant threats are malformed/future project payloads being treated as current truth; a local directory participant racing or pre-creating recovery names; link/reparse redirection; file replacement between preflight and publication; interruption during replacement/rollback; permissive modes exposing rehearsal data to another local account; executable renderer object shapes crossing the adapter; and stale playback/user-path authority being persisted as project truth. These controls do not claim protection against a fully compromised OS or 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. +Version 3 defines only the source-reference schema. Resource Admission still references the selected external absolute source and retains bootstrap state only in process memory; mounted project load still clears that bootstrap. #970/#962 must materialize the admitted full mix under the app-owned project namespace, write the reference only after successful materialization, re-admit it after process restart, and add stronger content identity if required for reproducibility. #1160 then has to compose the stored semantic with fresh native availability and fail closed to Full mix when a stem is unavailable. + +Parent authority is still path-based after lexical validation rather than descriptor-bound. Autosave, known-good backup rotation, global startup recovery, deterministic migration receipts/hashes, downgrade behavior, exhaustive interruption/power-loss injection, mounted Save/Reopen UX, and packaged Windows/macOS real-audio acceptance remain #962/release work and must not be described as shipped or fully crash-safe without exact-head evidence. From 7653c804d7eeeea4e742b21986917ff414341a61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:33:48 +0900 Subject: [PATCH 248/448] docs(traceability): ground v3 source reference in SSDF and CWE --- .../project-format-v3-source-reference.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md index 00c806ee1..7392cf5cf 100644 --- a/docs/traceability/project-format-v3-source-reference.md +++ b/docs/traceability/project-format-v3-source-reference.md @@ -42,9 +42,11 @@ The contract accepts only: The field is optional because v2/v1/legacy projects cannot prove that an app-owned source artifact exists. Their ordered migration writes version 3 with no invented reference. `selectedPlaybackSource` remains independent: it is rehearsal intent, while `sourceReference` identifies only the app-owned full-mix artifact required to rebuild native availability. +The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. Accordingly, a future reopen path must derive the artifact below the validated app-owned project root rather than trust a persisted path, and must re-check link/reparse and file identity at access time. These references justify the threat model; they do not constitute evidence that re-admission is already implemented. + ## Rejected alternatives -**Persist the original absolute path.** Rejected because it leaks local filesystem information, becomes stale when the file moves, and gives the project document filesystem authority. +**Persist the original absolute path.** Rejected because it leaks local filesystem information, becomes stale when the file moves, gives the project document filesystem authority, and reintroduces a path-traversal-shaped input at reopen. **Persist `bandscope-playback://...`.** Rejected because the URL is a revocable runtime capability whose generation and availability are session-specific. @@ -62,7 +64,7 @@ The field is optional because v2/v1/legacy projects cannot prove that an app-own - `5203c2846dd2d12a02ad54204e9c6b5197d1177f` — updates the engineering format document to the code-current v3 contract and migration boundary. - `ace91a29e540919d02716dd492e290f9743422a8` — repairs stale v2-output expectations and typed-constructor compilation after the version advance without weakening v2 input compatibility. -Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. +Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. The test-first/root-cause record also follows the released NIST SSDF 1.1 principle of integrating secure-development practices into the SDLC and addressing vulnerability root causes rather than treating a passing downstream check as the sole control. NIST published SSDF 1.2 only as SP 800-218 Rev. 1 Initial Public Draft in December 2025; this traceability therefore treats v1.1 as the released reference and the v1.2 draft as non-normative tracking input. ## Security Notes @@ -76,7 +78,7 @@ Native and TypeScript boundaries reject unknown source-reference fields. Project ### Safe failure -Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. A future re-admission implementation must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, or fails audio decode/admission checks. +Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. A future re-admission implementation must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, or fails audio decode/admission checks. CWE-59 specifically makes link resolution before file access part of the threat model, so a lexical containment check alone is not sufficient acceptance evidence. ### Logging and privacy @@ -89,3 +91,13 @@ The durable reference intentionally excludes the original local path and origina ### Remaining risk Version 3 is a schema foundation, not completed source re-admission. Current Resource Admission still stores bootstrap source information in process memory and uses the selected external source path. The next causal slice must materialize the admitted full mix under the app-owned project namespace, write `sourceReference` only after that succeeds, and reconstruct a fresh bootstrap from the validated reference on reopen. Content digest/stronger bounded identity, cleanup/retention policy for app-owned audio, crash injection during materialization, and rights-cleared Windows/macOS real-audio acceptance remain required before this path can be called release-ready. + +## References + +MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted directory ('Path Traversal')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/22.html + +MITRE. (2026). *CWE-59: Improper link resolution before file access ('Link Following')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/59.html + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd From 39fb18192f55f2f28fdf97d8d213efad75f987e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:45:45 +0900 Subject: [PATCH 249/448] test(project): require durable source content identity --- .../project_format_v3_source_reference.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/core/tests/project_format_v3_source_reference.rs b/apps/desktop/core/tests/project_format_v3_source_reference.rs index 0f6fdc7ce..84ef230eb 100644 --- a/apps/desktop/core/tests/project_format_v3_source_reference.rs +++ b/apps/desktop/core/tests/project_format_v3_source_reference.rs @@ -117,3 +117,24 @@ fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { ); } } + +#[test] +fn current_project_rejects_a_source_reference_without_content_identity() { + let content = json!({ + "projectFormatVersion": 3, + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "full_mix" }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + } + }) + .to_string(); + + assert!( + project_document_from_content(&content).is_err(), + "a durable source reference must carry content identity, not byte length alone" + ); +} From ac5a080576a5ed40e0e997c6bb0ba37b90f1455d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:46:21 +0900 Subject: [PATCH 250/448] test(project): constrain source digest evidence --- .../project_format_v3_source_reference.rs | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/apps/desktop/core/tests/project_format_v3_source_reference.rs b/apps/desktop/core/tests/project_format_v3_source_reference.rs index 84ef230eb..5a241b123 100644 --- a/apps/desktop/core/tests/project_format_v3_source_reference.rs +++ b/apps/desktop/core/tests/project_format_v3_source_reference.rs @@ -4,6 +4,8 @@ use bandscope_desktop_core::{ }; use serde_json::{json, Value}; +const CONTENT_SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + fn v2_song() -> Value { let fixture: Value = serde_json::from_str(include_str!("../testdata/project-v2.json")) .expect("the checked-in v2 fixture should remain valid JSON"); @@ -20,7 +22,8 @@ fn current_project_round_trips_an_app_owned_source_reference_without_a_filesyste "projectId": "project-400-4", "artifactName": "source.wav", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 } }) .to_string(); @@ -34,6 +37,7 @@ fn current_project_round_trips_an_app_owned_source_reference_without_a_filesyste artifact_name: "source.wav".to_string(), extension: "wav".to_string(), file_size_bytes: 4096, + content_sha256: CONTENT_SHA256.to_string(), }) ); @@ -44,6 +48,7 @@ fn current_project_round_trips_an_app_owned_source_reference_without_a_filesyste assert_eq!(value["projectFormatVersion"], json!(CURRENT_PROJECT_FORMAT_VERSION)); assert_eq!(value["sourceReference"]["projectId"], json!("project-400-4")); assert_eq!(value["sourceReference"]["artifactName"], json!("source.wav")); + assert_eq!(value["sourceReference"]["contentSha256"], json!(CONTENT_SHA256)); assert!(serialized.find("sourcePath").is_none()); assert!(serialized.find("bandscope-playback://").is_none()); } @@ -69,39 +74,72 @@ fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { "projectId": "../escape", "artifactName": "source.wav", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 }), json!({ "projectId": "project-400-4", "artifactName": "../source.wav", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 }), json!({ "projectId": "project-400-4", "artifactName": "source.mp3", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 }), json!({ "projectId": "project-400-4", "artifactName": "source.wav", "extension": "exe", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 }), json!({ "projectId": "project-400-4", "artifactName": "source.wav", "extension": "wav", - "fileSizeBytes": 0 + "fileSizeBytes": 0, + "contentSha256": CONTENT_SHA256 }), json!({ "projectId": "project-400-4", "artifactName": "source.wav", "extension": "wav", "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256, "sourcePath": "/Users/example/Music/private.wav" }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096 + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef" + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": "g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }), ] { let content = json!({ "projectFormatVersion": 3, @@ -113,28 +151,7 @@ fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { assert!( project_document_from_content(&content).is_err(), - "unsafe source reference must fail closed" + "unsafe or ambiguous source reference must fail closed" ); } } - -#[test] -fn current_project_rejects_a_source_reference_without_content_identity() { - let content = json!({ - "projectFormatVersion": 3, - "song": v2_song(), - "preferences": { "selectedPlaybackSource": "full_mix" }, - "sourceReference": { - "projectId": "project-400-4", - "artifactName": "source.wav", - "extension": "wav", - "fileSizeBytes": 4096 - } - }) - .to_string(); - - assert!( - project_document_from_content(&content).is_err(), - "a durable source reference must carry content identity, not byte length alone" - ); -} From c2117f2a41e2c1db84aba6332c069dda59b5cad2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:46:46 +0900 Subject: [PATCH 251/448] fix(project): require SHA-256 source identity evidence --- apps/desktop/core/src/project_format.rs | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index 9de0777e5..011bc99db 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -58,8 +58,8 @@ impl Default for ProjectPreferencesPayload { /// /// The reference deliberately stores no absolute/relative user path. Native /// Resource Admission derives the artifact location from `project_id` and the -/// fixed `source.` artifact name, then re-validates the byte length -/// before issuing any fresh runtime authority. +/// fixed `source.` artifact name, then re-validates byte length and +/// SHA-256 content identity before issuing any fresh runtime authority. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ProjectSourceReferencePayload { @@ -71,6 +71,8 @@ pub struct ProjectSourceReferencePayload { pub extension: String, /// Expected non-zero byte length used as bounded re-admission evidence. pub file_size_bytes: u64, + /// Canonical lowercase SHA-256 digest of the admitted app-owned audio bytes. + pub content_sha256: String, } /// Current typed project document after historical migration. @@ -109,10 +111,18 @@ fn unsupported_version(version: u64) -> String { format!("Unsupported project format version: {version}") } +fn sha256_hex_is_canonical(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool { if !is_valid_project_id(&reference.project_id) || reference.file_size_bytes == 0 || !AUDIO_EXTENSIONS.contains(&reference.extension.as_str()) + || !sha256_hex_is_canonical(&reference.content_sha256) { return false; } @@ -138,8 +148,9 @@ fn validate_document(document: ProjectDocumentPayload) -> Result Result { let document = serde_json::from_value::(value) .map_err(|_| "Invalid project document payload".to_string())?; @@ -151,10 +162,12 @@ pub fn project_document_from_value(value: Value) -> Result Result { let root = serde_json::from_str::(content) .map_err(|_| "Invalid project file format".to_string())?; From 16e54784c720e048d29d545643c5928b6d1265d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:47:12 +0900 Subject: [PATCH 252/448] test(project): require source digest across renderer bridge --- .../src/lib/projectDocumentBridge.test.ts | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/lib/projectDocumentBridge.test.ts b/apps/desktop/src/lib/projectDocumentBridge.test.ts index 87287f380..56e36482f 100644 --- a/apps/desktop/src/lib/projectDocumentBridge.test.ts +++ b/apps/desktop/src/lib/projectDocumentBridge.test.ts @@ -19,6 +19,7 @@ const SOURCE_SEMANTICS: SelectedPlaybackSource[] = [ "drums", "other" ]; +const CONTENT_SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; describe("project document bridge", () => { beforeEach(() => { @@ -47,7 +48,7 @@ describe("project document bridge", () => { } ); - it("persists only an app-owned source reference and never a user filesystem path", async () => { + it("persists only an app-owned source reference with content identity and never a user filesystem path", async () => { const invoke = vi.fn().mockResolvedValue(undefined); tauriWindow.__TAURI_INVOKE__ = invoke; const song = createDemoRehearsalSong(); @@ -59,7 +60,8 @@ describe("project document bridge", () => { projectId: "project-400-4", artifactName: "source.wav", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 } }); @@ -71,14 +73,15 @@ describe("project document bridge", () => { projectId: "project-400-4", artifactName: "source.wav", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 } } }); expect(JSON.stringify(invoke.mock.calls)).not.toContain("sourcePath"); }); - it("returns the persisted source semantic with the reopened song", async () => { + it("returns the persisted source semantic and content identity with the reopened song", async () => { const song = createDemoRehearsalSong(); tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ song, @@ -87,7 +90,8 @@ describe("project document bridge", () => { projectId: "project-400-4", artifactName: "source.flac", extension: "flac", - fileSizeBytes: 8192 + fileSizeBytes: 8192, + contentSha256: CONTENT_SHA256 } }); @@ -98,7 +102,8 @@ describe("project document bridge", () => { projectId: "project-400-4", artifactName: "source.flac", extension: "flac", - fileSizeBytes: 8192 + fileSizeBytes: 8192, + contentSha256: CONTENT_SHA256 } }); }); @@ -115,33 +120,57 @@ describe("project document bridge", () => { await expect(loadProjectDocument()).rejects.toThrow("Invalid project document"); }); - it("rejects user paths and mismatched app-owned artifact names in source references", async () => { + it("rejects user paths, missing digests, and mismatched app-owned source evidence", async () => { const song = createDemoRehearsalSong(); for (const sourceReference of [ { projectId: "../escape", artifactName: "source.wav", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 }, { projectId: "project-400-4", artifactName: "../source.wav", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 }, { projectId: "project-400-4", artifactName: "source.mp3", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 }, { projectId: "project-400-4", artifactName: "source.wav", extension: "wav", fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256, sourcePath: "/Users/example/Music/private.wav" + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: "0123456789abcdef" + }, + { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" } ]) { tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ From 7e853c5d6c40a35128afcf356536d2ca147ad109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:47:43 +0900 Subject: [PATCH 253/448] fix(project): enforce source digest in renderer admission --- apps/desktop/src/lib/projectDocument.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index be4e924da..93cc640da 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -14,6 +14,7 @@ export type ProjectSourceReference = { artifactName: string; extension: "wav" | "mp3" | "flac" | "m4a"; fileSizeBytes: number; + contentSha256: string; }; /** Current renderer-facing project document admitted by the native persistence owner. */ @@ -37,6 +38,7 @@ const PROJECT_SOURCE_EXTENSIONS = new Set([ "m4a" ]); const PROJECT_ID_PATTERN = /^project-\d+-\d+$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; type OwnDataProperty = | { ok: true; value: unknown } @@ -117,7 +119,7 @@ function optionalOwnEnumerableDataProperty( function parseProjectSourceReference(value: unknown): ProjectSourceReference { if ( !isPlainRecord(value) || - !hasOnlyKeys(value, ["projectId", "artifactName", "extension", "fileSizeBytes"]) + !hasOnlyKeys(value, ["projectId", "artifactName", "extension", "fileSizeBytes", "contentSha256"]) ) { throw new Error("Invalid project document"); } @@ -126,11 +128,13 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { const artifactNameProperty = ownEnumerableDataProperty(value, "artifactName"); const extensionProperty = ownEnumerableDataProperty(value, "extension"); const fileSizeBytesProperty = ownEnumerableDataProperty(value, "fileSizeBytes"); + const contentSha256Property = ownEnumerableDataProperty(value, "contentSha256"); if ( !projectIdProperty.ok || !artifactNameProperty.ok || !extensionProperty.ok || - !fileSizeBytesProperty.ok + !fileSizeBytesProperty.ok || + !contentSha256Property.ok ) { throw new Error("Invalid project document"); } @@ -139,6 +143,7 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { const artifactName = artifactNameProperty.value; const extension = extensionProperty.value; const fileSizeBytes = fileSizeBytesProperty.value; + const contentSha256 = contentSha256Property.value; if ( typeof projectId !== "string" || !PROJECT_ID_PATTERN.test(projectId) || @@ -148,7 +153,9 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { artifactName !== `source.${extension}` || typeof fileSizeBytes !== "number" || !Number.isSafeInteger(fileSizeBytes) || - fileSizeBytes <= 0 + fileSizeBytes <= 0 || + typeof contentSha256 !== "string" || + !SHA256_HEX_PATTERN.test(contentSha256) ) { throw new Error("Invalid project document"); } @@ -157,14 +164,15 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { projectId, artifactName, extension: extension as ProjectSourceReference["extension"], - fileSizeBytes + fileSizeBytes, + contentSha256 }; } /** * Validate the renderer-visible project document without accepting filesystem paths, * runtime capability URLs, generation tokens, prototype-bearing records, accessors, - * trapped record enumeration, or unknown preference/source-reference fields. + * trapped record enumeration, ambiguous source digests, or unknown preference/source-reference fields. */ export function parseProjectDocument(value: unknown): ProjectDocument { if ( From a684ab555e91572dcfc00496ab2f9aacfe822aa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:50:51 +0900 Subject: [PATCH 254/448] docs(project): trace SHA-256 source identity decision --- .../project-format-v3-source-reference.md | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md index 7392cf5cf..2b5704294 100644 --- a/docs/traceability/project-format-v3-source-reference.md +++ b/docs/traceability/project-format-v3-source-reference.md @@ -4,13 +4,16 @@ Project format v2 can persist the Active Player selection semantic, but it cannot identify the admitted full-mix artifact needed after the desktop process restarts. The current mounted reopen path therefore recovers the song while clearing its bootstrap/source authority. Persisting the existing absolute `sourcePath` or a revocable `bandscope-playback` URL would make a user filesystem path or runtime capability part of durable project truth. +The first v3 source-reference draft narrowed location authority correctly but retained only `fileSizeBytes` as content evidence. Byte length is not content identity: different audio bytes can have the same size. Treating size equality as sufficient re-admission evidence would let a replaced or corrupted app-owned source satisfy the durable reference and undermine rehearsal reproducibility. + ## Constraints - Project Persistence owns the `.bscope` schema and migrations; Resource Admission owns audio admission/materialization; Active Player owns playback selection and fresh runtime authority resolution. - Historical projects must migrate deterministically. Missing evidence must stay missing rather than being inferred. - A durable source handle must not contain a user filesystem path, WebView storage key, generation token, or runtime playback URL. - Renderer and file input are untrusted and must remain passive JSON data. -- The source handle has to be sufficient for a later native re-admission implementation to derive an app-owned artifact without cross-service SQL or another writable authority. +- The source handle has to be sufficient for later native re-admission to derive and verify an app-owned artifact without cross-service SQL or another writable authority. +- The v3 format is still Draft/unreleased work in #970, so tightening the v3 source-reference contract before merge is preferable to publishing an underspecified same-version schema and then maintaining it as compatibility debt. ## RED evidence @@ -18,7 +21,11 @@ Project format v2 can persist the Active Player selection semantic, but it canno `6acd761f8a25b904352b2ae4eebcbc4f61ec5a48` extended the renderer/native bridge test with the same source-reference shape. The predecessor TypeScript parser admitted only `song` and `preferences`, so the new current-document payload was rejected. -A fresh post-change sweep then found a separate migration-test regression before hosted CI could be treated as evidence: `project_format_v2_playback_preference.rs` still hard-coded serialized version `2` and directly constructed `ProjectDocumentPayload` without the new optional field. That was not a product-format rollback signal; it was predecessor test code that had not been migrated with the format owner. `ace91a29e540919d02716dd492e290f9743422a8` updates those assertions to `CURRENT_PROJECT_FORMAT_VERSION`, explicitly checks that historical migrations do not invent `sourceReference`, and adds `source_reference: None` to the typed constructor. This repair preserves the v2 input compatibility contract while making current-output expectations version-aware. +A fresh post-change sweep found a separate migration-test regression before hosted CI could be treated as evidence: `project_format_v2_playback_preference.rs` still hard-coded serialized version `2` and directly constructed `ProjectDocumentPayload` without the new optional field. `ace91a29e540919d02716dd492e290f9743422a8` repairs those expectations to `CURRENT_PROJECT_FORMAT_VERSION`, verifies historical migrations do not invent `sourceReference`, and restores the typed constructor without weakening v2 input compatibility. + +A later scientific/reproducibility review found that the initial v3 shape still admitted a source reference whose only content evidence was byte length. RED `39fb18192f55f2f28fdf97d8d213efad75f987e2`, refined in `ac5a080576a5ed40e0e997c6bb0ba37b90f1455d`, requires a durable source reference to carry content identity and rejects missing, shortened, uppercase, or non-hex digest representations. The predecessor accepted the digest-free shape, so this is a causal contract failure rather than a documentation-only finding. + +Renderer RED `16e54784c720e048d29d545643c5928b6d1265d5` applies the same requirement across the WebView/native boundary. A renderer response that omits or weakens content identity must be rejected before it can become durable project truth. ## Selected design @@ -29,7 +36,8 @@ Version 3 adds an optional `sourceReference`: "projectId": "project-400-4", "artifactName": "source.wav", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } ``` @@ -38,11 +46,14 @@ The contract accepts only: - the existing opaque `project--` namespace minted by BandScope; - `artifactName` exactly equal to `source.`; - one admitted extension: `wav`, `mp3`, `flac`, or `m4a`; -- a positive byte length. The renderer additionally requires a JavaScript safe integer so it cannot silently round persisted byte evidence. +- a positive byte length. The renderer additionally requires a JavaScript safe integer so it cannot silently round persisted byte evidence; +- a canonical lowercase 64-hex-character SHA-256 digest of the app-owned source bytes. + +`fileSizeBytes` remains useful as a bounded preflight and diagnostic signal but is not accepted as content identity. `contentSha256` is the durable equality check that later Resource Admission must recompute over the re-opened app-owned artifact before creating fresh runtime authority. FIPS 180-4 defines SHA-256 as part of the Secure Hash Standard; NIST's current CAVP secure-hashing material, updated in August 2026, continues to list SHA-256 under FIPS 180-4. NIST has announced a future revision of FIPS 180-4, but that revision has not replaced the current final standard. The field is optional because v2/v1/legacy projects cannot prove that an app-owned source artifact exists. Their ordered migration writes version 3 with no invented reference. `selectedPlaybackSource` remains independent: it is rehearsal intent, while `sourceReference` identifies only the app-owned full-mix artifact required to rebuild native availability. -The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. Accordingly, a future reopen path must derive the artifact below the validated app-owned project root rather than trust a persisted path, and must re-check link/reparse and file identity at access time. These references justify the threat model; they do not constitute evidence that re-admission is already implemented. +The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. A future reopen path must derive the artifact below the validated app-owned project root rather than trust a persisted path, re-check link/reparse and file identity at access time, verify size, recompute SHA-256, and only then re-run audio admission. These references justify the threat model; they do not constitute evidence that re-admission is already implemented. ## Rejected alternatives @@ -50,7 +61,11 @@ The path-free shape is also a security boundary, not merely a portability choice **Persist `bandscope-playback://...`.** Rejected because the URL is a revocable runtime capability whose generation and availability are session-specific. -**Persist the original file name and reconstruct a path heuristically.** Rejected because it retains unnecessary user metadata and is ambiguous. The fixed `source.` artifact name is both narrower and deterministic. +**Persist the original file name and reconstruct a path heuristically.** Rejected because it retains unnecessary user metadata and is ambiguous. The fixed `source.` artifact name is narrower and deterministic. + +**Use byte length as content identity.** Rejected because distinct byte sequences can have identical length. Size remains a bounded preflight, not proof that the source used for rehearsal decisions is the same admitted artifact. + +**Use a non-canonical or variable-length digest string.** Rejected because multiple textual forms enlarge the durable contract without benefit. The project format stores one canonical lowercase SHA-256 representation. **Infer a source reference while migrating v2.** Rejected because the old document carries no evidence that Resource Admission materialized an app-owned source. Fabricating one would turn a migration into a guess. @@ -63,6 +78,8 @@ The path-free shape is also a security boundary, not merely a portability choice - `c1cdcd036749a0a9231682db9446e5fbbe410d40` — verifies accessor/proxy-backed source-reference input is rejected without executing getters. - `5203c2846dd2d12a02ad54204e9c6b5197d1177f` — updates the engineering format document to the code-current v3 contract and migration boundary. - `ace91a29e540919d02716dd492e290f9743422a8` — repairs stale v2-output expectations and typed-constructor compilation after the version advance without weakening v2 input compatibility. +- `c2117f2a41e2c1db84aba6332c069dda59b5cad2` — requires canonical lowercase SHA-256 content identity in the native v3 source-reference contract. +- `7e853c5d6c40a35128afcf356536d2ca147ad109` — requires the same SHA-256 evidence in renderer admission and keeps digest/property inspection passive and fail closed. Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. The test-first/root-cause record also follows the released NIST SSDF 1.1 principle of integrating secure-development practices into the SDLC and addressing vulnerability root causes rather than treating a passing downstream check as the sole control. NIST published SSDF 1.2 only as SP 800-218 Rev. 1 Initial Public Draft in December 2025; this traceability therefore treats v1.1 as the released reference and the v1.2 draft as non-normative tracking input. @@ -74,26 +91,28 @@ Hosted exact-head checks are authoritative for repository GREEN; predecessor res ### Allowlist and validation -Native and TypeScript boundaries reject unknown source-reference fields. Project ids use the existing BandScope minted-id grammar. Artifact names are derived from the admitted extension and cannot contain path traversal. The extension is closed to the existing audio allowlist. Byte evidence must be positive; the renderer additionally rejects unsafe integers. +Native and TypeScript boundaries reject unknown source-reference fields. Project ids use the existing BandScope minted-id grammar. Artifact names are derived from the admitted extension and cannot contain path traversal. The extension is closed to the existing audio allowlist. Byte evidence must be positive; the renderer additionally rejects unsafe integers. `contentSha256` must be exactly 64 lowercase hexadecimal characters. The string is evidence to be verified, not trusted merely because its syntax is valid. ### Safe failure -Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. A future re-admission implementation must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, or fails audio decode/admission checks. CWE-59 specifically makes link resolution before file access part of the threat model, so a lexical containment check alone is not sufficient acceptance evidence. +Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. Re-admission must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, has a SHA-256 mismatch, or fails audio decode/admission checks. CWE-59 specifically makes link resolution before file access part of the threat model, so lexical containment plus matching digest syntax is not sufficient acceptance evidence. ### Logging and privacy -The durable reference intentionally excludes the original local path and original file name. Error reporting should continue using bounded/redacted buyer copy and must not add the derived app-owned path to renderer-visible diagnostics unless there is a separate explicit diagnostic contract. +The durable reference intentionally excludes the original local path and original file name. The SHA-256 digest is content-derived metadata and must be treated as purpose-bound project integrity evidence rather than a user identifier. Error reporting should continue using bounded/redacted buyer copy and must not add the derived app-owned path to renderer-visible diagnostics unless there is a separate explicit diagnostic contract. ### Test points -`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, and unknown `sourcePath` rejection. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. +`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, unknown `sourcePath` rejection, and canonical SHA-256 requirements. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary, including digest presence and canonical representation. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. ### Remaining risk -Version 3 is a schema foundation, not completed source re-admission. Current Resource Admission still stores bootstrap source information in process memory and uses the selected external source path. The next causal slice must materialize the admitted full mix under the app-owned project namespace, write `sourceReference` only after that succeeds, and reconstruct a fresh bootstrap from the validated reference on reopen. Content digest/stronger bounded identity, cleanup/retention policy for app-owned audio, crash injection during materialization, and rights-cleared Windows/macOS real-audio acceptance remain required before this path can be called release-ready. +Version 3 is a schema/admission foundation, not completed source re-admission. Current Resource Admission still stores bootstrap source information in process memory and uses the selected external source path. The next causal slice must materialize the admitted full mix under the app-owned project namespace, compute `contentSha256` from the bytes that were actually published, write `sourceReference` only after publication and digest calculation succeed, and reconstruct a fresh bootstrap from the validated reference on reopen. Reopen must recompute SHA-256 before issuing playback authority. Cleanup/retention policy for app-owned audio, crash injection during materialization, and rights-cleared Windows/macOS real-audio acceptance remain required before this path can be called release-ready. ## References +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted directory ('Path Traversal')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/22.html MITRE. (2026). *CWE-59: Improper link resolution before file access ('Link Following')* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/59.html From 5ec94655d050f8a7db0fe74a59f63f4a521af8f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:51:23 +0900 Subject: [PATCH 255/448] docs(project): make v3 source identity code-current --- docs/engineering/local-project-format.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index e69deb2f1..507a02ce2 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -4,7 +4,7 @@ This document specifies the format and lifecycle of a BandScope `.bscope` projec ## Overview -BandScope projects are saved as `.bscope` files. Current writes use a strict JSON envelope with `projectFormatVersion: 3`. The nested `song` remains the compatibility view used by the desktop rehearsal contract, `preferences` stores durable rehearsal UI intent, and the optional `sourceReference` is the first typed handle for locating an app-owned full-mix artifact after process restart. +BandScope projects are saved as `.bscope` files. Current writes use a strict JSON envelope with `projectFormatVersion: 3`. The nested `song` remains the compatibility view used by the desktop rehearsal contract, `preferences` stores durable rehearsal UI intent, and the optional `sourceReference` is the typed handle for locating and verifying an app-owned full-mix artifact after process restart. Version 2, version 1, and older raw `RehearsalSong` JSON remain supported inputs. Version 2 is migrated with its existing `preferences` and no invented source reference. Version 1 and legacy song JSON are migrated with `preferences.selectedPlaybackSource = "full_mix"` and no source reference. A migration does not infer a source artifact that the historical file never recorded. @@ -42,20 +42,21 @@ The rehearsal content inside `song` is the `RehearsalSong` contract from `@bands "projectId": "project-400-4", "artifactName": "source.wav", "extension": "wav", - "fileSizeBytes": 4096 + "fileSizeBytes": 4096, + "contentSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } } ``` `selectedPlaybackSource` is a closed durable semantic with exactly these values: `full_mix`, `vocals`, `bass`, `drums`, or `other`. It is not a media URL, local path, generation receipt, or native playback authority. An opaque `bandscope-playback` authority is runtime-only and must never appear in a `.bscope` file. -`sourceReference` is optional because historical projects and compatibility callers do not have enough evidence to invent one. When present, it is restricted to an opaque BandScope `projectId`, the fixed app-owned artifact name `source.`, one of `wav | mp3 | flac | m4a`, and a non-zero byte length. It contains no source path. The current source-reference schema is a prerequisite for process-restart re-admission; it does not by itself prove that Resource Admission has already materialized or reopened the corresponding artifact. +`sourceReference` is optional because historical projects and compatibility callers do not have enough evidence to invent one. When present, it is restricted to an opaque BandScope `projectId`, the fixed app-owned artifact name `source.`, one of `wav | mp3 | flac | m4a`, a non-zero byte length, and a canonical lowercase SHA-256 digest of the admitted app-owned source bytes. It contains no source path. `fileSizeBytes` is bounded preflight evidence; it is not sufficient content identity. `contentSha256` must be recomputed by native Resource Admission before an app-owned source is accepted after restart. The current source-reference schema is therefore a prerequisite for process-restart re-admission; it does not by itself prove that Resource Admission has already materialized or reopened the corresponding artifact. The native `save_project`/`load_project` commands admit and return the complete typed current document, and the TypeScript Project Persistence adapter exposes `saveProjectDocument`/`loadProjectDocument` with the same closed preference/source-reference domains. Existing song-only `saveProject`/`loadProject` callers remain compatibility adapters and do not invent a source reference. The mounted Active Player still has to compose its selected semantic and current source reference into this bridge, then resolve the reopened semantic through freshly re-admitted native source availability. `tempo` and `collaboration` are optional song fields. The native persistence boundary preserves the current shared collaboration contract and its assignment/comment/approval state domains. Role records also preserve optional `harmonicExplanation`, `transpositionPlan`, `transcription`, and integer `practiceProgress` from 0 through 100. These fields are typed project data; unknown fields still fail closed rather than being retained in an untyped JSON bag. -The project format version is independent of the application package version. Version 3 rejects unknown envelope fields, invalid preference tokens, user-path-shaped source reference fields, mismatched artifact names/extensions, invalid project ids, and zero-length source evidence. A well-formed unsupported future version returns an explicit unsupported-version error before its body is interpreted as current truth. +The project format version is independent of the application package version. Version 3 rejects unknown envelope fields, invalid preference tokens, user-path-shaped source reference fields, mismatched artifact names/extensions, invalid project ids, zero-length source evidence, and non-canonical or missing SHA-256 content identity. A well-formed unsupported future version returns an explicit unsupported-version error before its body is interpreted as current truth. Checked-in compatibility evidence: @@ -63,7 +64,7 @@ Checked-in compatibility evidence: - `apps/desktop/core/testdata/project-v2.json` — supported version-2 document with an explicit `vocals` preference. - `apps/desktop/core/tests/project_format_v2_playback_preference.rs` — legacy/v1 migration and closed preference-domain contracts. - `apps/desktop/core/tests/project_format_v2_fixture.rs` — version-2 fixture migration and current serialization. -- `apps/desktop/core/tests/project_format_v3_source_reference.rs` — current source-reference round trip, v2 migration, and fail-closed path/shape tests. +- `apps/desktop/core/tests/project_format_v3_source_reference.rs` — current source-reference round trip, v2 migration, path/shape rejection, and canonical SHA-256 requirements. - `apps/desktop/src/lib/projectDocumentBridge.test.ts` — renderer/native bridge contract for stable source semantics and source-reference admission. - `apps/desktop/src/lib/projectDocument.plainRecord.test.ts` — passive JSON-record admission, including accessor/proxy rejection without executing getters. @@ -80,6 +81,8 @@ Version 1 had the shape below and did not contain project-level preferences: Version 2 added only the typed preferences section. The ordered v1 → v2 migration created `preferences.selectedPlaybackSource = "full_mix"`; legacy raw-song input followed the same rule. Version 3 retains that preference and adds no source reference unless one is explicitly supplied by the current Resource Admission/Project Persistence contract. Serializing any supported predecessor writes the current version-3 envelope, so reopening the result does not rerun heuristic inference. +The SHA-256 requirement was tightened while version 3 remained Draft/unreleased in #970. No released BandScope project format has depended on the earlier size-only v3 draft. This avoids creating a second same-version interpretation and keeps the future released v3 contract singular. + ### Sections and Roles Sections describe structural segments of the song (for example Intro, Verse, or Chorus). Each section contains a list of roles. @@ -133,14 +136,15 @@ When loading `.bscope` files from disk, BandScope applies these constraints: 2. **Strict schema validation** — current and historical envelopes plus the rehearsal song contract reject unknown fields according to their published compatibility rule. Playback preference, source reference, collaboration state, provenance, cue, role, export, and progress domains are typed rather than arbitrary strings. 3. **Bounded processing** — project JSON is parsed as data only. The format contains no executable code or runtime playback URL. 4. **Runtime-authority separation** — a selected playback source is stored only as a stable semantic. Reopening must request a fresh native authority from current resource availability rather than trusting persisted media capability data. -5. **Filesystem-authority separation** — `sourceReference` cannot carry an absolute/relative user path. Native code must derive any app-owned artifact path from the validated project id and fixed artifact basename, validate the artifact without following untrusted path input, and compare the recorded byte length before reuse. -6. **Purpose-bound metadata** — the source reference does not persist the user's original filesystem location. Its fields exist only to locate and verify BandScope-owned audio needed for rehearsal reopen. +5. **Filesystem-authority separation** — `sourceReference` cannot carry an absolute/relative user path. Native code must derive any app-owned artifact path from the validated project id and fixed artifact basename and validate the artifact without following untrusted path input. +6. **Content-identity separation** — `fileSizeBytes` is not treated as identity. Re-admission must compare the bounded byte length and recompute SHA-256 over the derived app-owned audio before the source is accepted. The persisted digest is required to be exactly 64 lowercase hexadecimal characters, but syntactic validity alone never grants file authority. +7. **Purpose-bound metadata** — the source reference does not persist the user's original filesystem location. Project id, fixed artifact name, byte length, and SHA-256 exist only to locate and verify BandScope-owned audio needed for rehearsal reopen. ## Current boundary and next migration slices -Version 3 establishes the durable source-reference schema and renderer/native admission contract. It does **not** complete source re-admission. Current local intake still keeps the selected source/bootstrap authority in process memory and must be changed so Resource Admission materializes the full mix under the app-owned project namespace before a valid `sourceReference` can be written. Reopen must then derive that artifact from the validated reference, verify its non-zero recorded byte length and admission rules, reconstruct a fresh bootstrap, and only afterward let Active Player resolve `selectedPlaybackSource` against current stem availability. +Version 3 establishes the durable source-reference schema and renderer/native admission contract. It does **not** complete source re-admission. Current local intake still keeps the selected source/bootstrap authority in process memory and must be changed so Resource Admission materializes the full mix under the app-owned project namespace before a valid `sourceReference` can be written. That publication must compute `contentSha256` from the exact bytes that become app-owned truth. Reopen must then derive that artifact from the validated reference, verify regular/no-link status, compare the recorded byte length, recompute SHA-256, rerun audio admission/decode checks, reconstruct a fresh bootstrap, and only afterward let Active Player resolve `selectedPlaybackSource` against current stem availability. -The source artifact itself must not be represented by an arbitrary filesystem path in the project file. A WebView `localStorage`/session store, a serialized `bandscope-playback` URL, or a copied external absolute path would create a second authority and is not an acceptable substitute. If the durable full-mix artifact is absent or fails re-admission, the UI must report that state rather than silently presenting a stale stem selection. +The source artifact itself must not be represented by an arbitrary filesystem path in the project file. A WebView `localStorage`/session store, a serialized `bandscope-playback` URL, or a copied external absolute path would create a second authority and is not an acceptable substitute. If the durable full-mix artifact is absent, differs from the recorded digest, or fails re-admission, the UI must report that state rather than silently presenting a stale stem selection. The remaining Project Persistence work also includes bounded autosave, known-good backup rotation, startup recovery discovery, accessible Restore / Compare / Discard UX, descriptor-bound parent authority, deterministic migration receipts/hashes, downgrade/rollback behavior, and exhaustive interruption/disk-full/power-loss fault injection. From 2f5634be69c50080658b80d12e216907aa210047 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:51:50 +0900 Subject: [PATCH 256/448] docs(changelog): record v3 source content identity --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237ccc1fb..b741f419d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. -- Evolve local project writes to `projectFormatVersion: 3`: retain deterministic legacy/v1/v2 migration, persist the closed Active Player source preference, and optionally store a path-free app-owned audio `sourceReference` for process-restart re-admission. +- Evolve local project writes to `projectFormatVersion: 3`: retain deterministic legacy/v1/v2 migration, persist the closed Active Player source preference, and optionally store a path-free app-owned audio `sourceReference` with bounded byte evidence and canonical SHA-256 content identity for process-restart re-admission. ### Changed @@ -24,7 +24,7 @@ - Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path. - Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. - Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. -- Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, and unsafe byte-size values fail closed before persistence IPC. +- Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. ## [0.1.3] - 2026-04-29 @@ -84,4 +84,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From 0e9e823905cd06e205b405b118dca1a5dcb00c83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:56:50 +0900 Subject: [PATCH 257/448] test(project): keep source digest admission passive --- .../lib/projectDocument.plainRecord.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index 51e5ccd8b..3feebbd03 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { parseProjectDocument } from "./projectDocument"; +const CONTENT_SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + class ProjectDocumentWithPrototype { song = createDemoRehearsalSong(); preferences = { selectedPlaybackSource: "vocals" }; @@ -114,6 +116,32 @@ describe("project document plain-record admission", () => { expect(getterCalls).toBe(0); }); + it("rejects an accessor-backed source digest without invoking the accessor", () => { + let getterCalls = 0; + const sourceReference = { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } as Record; + Object.defineProperty(sourceReference, "contentSha256", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("source digest getter must not run"); + } + }); + + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "vocals" }, + sourceReference + }) + ).toThrow("Invalid project document"); + expect(getterCalls).toBe(0); + }); + it("fails closed when optional source-reference descriptor inspection throws", () => { const document = new Proxy( { @@ -123,7 +151,8 @@ describe("project document plain-record admission", () => { projectId: "project-400-4", artifactName: "source.wav", extension: "wav", - fileSizeBytes: 4096 + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 } }, { @@ -167,4 +196,4 @@ describe("project document plain-record admission", () => { preferences: { selectedPlaybackSource: "vocals" } }); }); -}); +}); \ No newline at end of file From 45037f9fe5aa7c265d28c0da33fdedcf26f3ac49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:15:23 +0900 Subject: [PATCH 258/448] test(security): bound Security Notes section evidence --- .../tests/test_security_notes_policy.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 services/analysis-engine/tests/test_security_notes_policy.py diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py new file mode 100644 index 000000000..1f3e5c46c --- /dev/null +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -0,0 +1,33 @@ +"""Regression coverage for the repository Security Notes documentation contract.""" + +from pathlib import Path +import runpy + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SECURITY_NOTES_CHECK = runpy.run_path(str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py")) +security_notes_section = SECURITY_NOTES_CHECK["security_notes_section"] + + +def test_security_notes_section_stops_at_next_peer_heading() -> None: + """Do not let unrelated peer sections satisfy missing Security Notes evidence.""" + document = """# Example\n\n## Security Notes\n\nAttack surface and trust boundary are defined here.\n\n## Operations\n\nMitigations, test points, realistic threats, and remaining risk are documented elsewhere.\n""" + + section = security_notes_section(document) + + assert "attack surface" in section + assert "trust boundary" in section + assert "mitigations" not in section + assert "test points" not in section + assert "realistic threats" not in section + assert "remaining risk" not in section + + +def test_local_project_format_uses_required_security_notes_heading() -> None: + """Keep the project-format security section under the repository-mandated heading.""" + project_format = (REPO_ROOT / "docs" / "engineering" / "local-project-format.md").read_text( + encoding="utf-8" + ) + + assert "## Security Notes" in project_format + assert "## Security Constraints" not in project_format From 907f3a7f70db0ac9fce11839ee215600cec15abe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:15:40 +0900 Subject: [PATCH 259/448] fix(security): stop Security Notes evidence at peer headings --- scripts/checks/verify_security_notes.py | 39 ++++++++++++++++--------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index 7edc1597f..18f8e3aa2 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -1,6 +1,7 @@ """Verify that security-sensitive design and traceability documents include Security Notes.""" from pathlib import Path +import re SECURITY_NOTES_TEXT = "Security Notes" SECURITY_NOTE_DIRS = (Path("docs/plans"), Path("docs/traceability")) @@ -12,26 +13,36 @@ "realistic threats", "remaining risk", ] +MARKDOWN_HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") def security_notes_section(content: str) -> str: - """Extract the lowercased Security Notes section from a governed document.""" - lowered = content.lower() - marker = SECURITY_NOTES_TEXT.lower() - start = lowered.find(marker) - if start == -1: - return "" + """Extract only the lowercased Security Notes section from a governed document.""" + lines = content.splitlines() + start_index: int | None = None + heading_level: int | None = None + + for index, line in enumerate(lines): + match = MARKDOWN_HEADING.match(line.strip()) + if match is None: + continue + heading_text = match.group(2).rstrip("#").strip() + if heading_text.casefold() == SECURITY_NOTES_TEXT.casefold(): + start_index = index + heading_level = len(match.group(1)) + break - end_candidates = [] - for delimiter in ["\n---", "\n## approaches considered", "\n## decision"]: - end = lowered.find(delimiter, start + len(marker)) - if end != -1: - end_candidates.append(end) + if start_index is None or heading_level is None: + return "" - if not end_candidates: - return lowered[start:] + end_index = len(lines) + for index in range(start_index + 1, len(lines)): + match = MARKDOWN_HEADING.match(lines[index].strip()) + if match is not None and len(match.group(1)) == heading_level: + end_index = index + break - return lowered[start : min(end_candidates)] + return "\n".join(lines[start_index:end_index]).lower() def governed_documents() -> list[Path]: From 00418cb6854835e59650bf64dfcfec817dc779e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:16:05 +0900 Subject: [PATCH 260/448] docs(project): use required Security Notes heading --- docs/engineering/local-project-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 507a02ce2..980f5d743 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -128,7 +128,7 @@ BandScope records user corrections in the `manualOverrides` array on a `Rehearsa } ``` -## Security Constraints +## Security Notes When loading `.bscope` files from disk, BandScope applies these constraints: From 70c23132da619a1776c477258d0a05dc27da4aad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:16:31 +0900 Subject: [PATCH 261/448] docs(project): document passive admission helpers --- apps/desktop/src/lib/projectDocument.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index 93cc640da..6e9421d1a 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -48,6 +48,7 @@ type OptionalOwnDataProperty = | { ok: true; present: true; value: unknown } | { ok: false; present: false }; +/** Accept only passive JSON-style records; prototype inspection traps fail closed. */ function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; @@ -61,6 +62,7 @@ function isPlainRecord(value: unknown): value is Record { } } +/** Confirm a record exposes exactly the allowed enumerable own keys; enumeration traps fail closed. */ function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { try { const keys = Object.keys(value); @@ -70,6 +72,7 @@ function hasOnlyKeys(value: Record, allowedKeys: readonly strin } } +/** Confirm required keys exist and every enumerable own key belongs to the declared project schema. */ function hasRequiredAndOptionalKeys( value: Record, requiredKeys: readonly string[], @@ -86,6 +89,7 @@ function hasRequiredAndOptionalKeys( } } +/** Read an enumerable own data property without invoking accessors; descriptor traps fail closed. */ function ownEnumerableDataProperty(value: Record, key: string): OwnDataProperty { try { const descriptor = Object.getOwnPropertyDescriptor(value, key); @@ -98,6 +102,7 @@ function ownEnumerableDataProperty(value: Record, key: string): } } +/** Read an optional enumerable own data property without invoking accessors; descriptor traps fail closed. */ function optionalOwnEnumerableDataProperty( value: Record, key: string @@ -116,6 +121,7 @@ function optionalOwnEnumerableDataProperty( } } +/** Validate path-free app-owned audio identity before admitting it as durable project truth. */ function parseProjectSourceReference(value: unknown): ProjectSourceReference { if ( !isPlainRecord(value) || From 08375705429ee40c3911521b0816ce719242094d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:16:56 +0900 Subject: [PATCH 262/448] test(security): keep policy regression lint-clean --- .../analysis-engine/tests/test_security_notes_policy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index 1f3e5c46c..14a4fe0d2 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -1,11 +1,13 @@ """Regression coverage for the repository Security Notes documentation contract.""" -from pathlib import Path import runpy +from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] -SECURITY_NOTES_CHECK = runpy.run_path(str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py")) +SECURITY_NOTES_CHECK = runpy.run_path( + str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py") +) security_notes_section = SECURITY_NOTES_CHECK["security_notes_section"] From 65e422cd4d39014f2586ca86616f25b4b17e5e82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:20:15 +0900 Subject: [PATCH 263/448] test(security): stop nested notes at parent section --- .../tests/test_security_notes_policy.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index 14a4fe0d2..432897c73 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -25,6 +25,20 @@ def test_security_notes_section_stops_at_next_peer_heading() -> None: assert "remaining risk" not in section +def test_security_notes_section_stops_when_parent_section_resumes() -> None: + """Keep a nested Security Notes section from consuming a later parent section.""" + document = """# Example\n\n## Design\n\n### Security Notes\n\nAttack surface and trust boundary are defined here.\n\n## Operations\n\nMitigations, test points, realistic threats, and remaining risk are documented elsewhere.\n""" + + section = security_notes_section(document) + + assert "attack surface" in section + assert "trust boundary" in section + assert "mitigations" not in section + assert "test points" not in section + assert "realistic threats" not in section + assert "remaining risk" not in section + + def test_local_project_format_uses_required_security_notes_heading() -> None: """Keep the project-format security section under the repository-mandated heading.""" project_format = (REPO_ROOT / "docs" / "engineering" / "local-project-format.md").read_text( From d1ba145d9cdd7126df240a02d0c07f253a80d3c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:20:29 +0900 Subject: [PATCH 264/448] fix(security): stop Security Notes at parent headings --- scripts/checks/verify_security_notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index 18f8e3aa2..a32b12110 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -38,7 +38,7 @@ def security_notes_section(content: str) -> str: end_index = len(lines) for index in range(start_index + 1, len(lines)): match = MARKDOWN_HEADING.match(lines[index].strip()) - if match is not None and len(match.group(1)) == heading_level: + if match is not None and len(match.group(1)) <= heading_level: end_index = index break From 3883f342ac427fbed35fe2a88c4c6e7dd2f6a499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:20:43 +0900 Subject: [PATCH 265/448] style(security): keep verifier imports canonical --- scripts/checks/verify_security_notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index a32b12110..0bde13588 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -1,7 +1,7 @@ """Verify that security-sensitive design and traceability documents include Security Notes.""" -from pathlib import Path import re +from pathlib import Path SECURITY_NOTES_TEXT = "Security Notes" SECURITY_NOTE_DIRS = (Path("docs/plans"), Path("docs/traceability")) From 08e5531248070eb209c8f3ddfa8ed71026a3ef18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:21:19 +0900 Subject: [PATCH 266/448] docs(traceability): record bounded Security Notes evidence --- .../project-persistence-shared-song-contract.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/traceability/project-persistence-shared-song-contract.md b/docs/traceability/project-persistence-shared-song-contract.md index 95a1077b8..3969384b2 100644 --- a/docs/traceability/project-persistence-shared-song-contract.md +++ b/docs/traceability/project-persistence-shared-song-contract.md @@ -19,6 +19,7 @@ The desktop shared contract already permits collaboration data and role-level re - `ed61d1c5f10e2baa4290fb40d692b82fb7dde500` proves explicit `null` is not omission for collaboration, collaboration `roleId`, and role explanation/transposition/transcription fields. `8b4ae848ec360a5af42b50076af15b643ae5275e` implements present-value deserialization; `ed9abedf0e5069fa93780fa3440ca91500cbdd93` extends it to optional `scoreAttachments`. - `2b0a47e6305b7b7a3e87857335d0f36dfabc9712` adds negative closed-domain cases; `96d66ed6f5fad918b0ddef8a1e6494b76f8bafd0` replaces unrestricted strings with exact serde enums. Manual overrides use a user-only harmony payload so outer `source: "user"` cannot mask model-owned nested provenance. `f8c30150375b39d54e1775d941f6515d2686410c` exercises every valid section-form, confidence, provenance, role-type, cue-kind, rehearsal-priority, and export-format token. - `d7886876b285f16ceda83ff5e0dd848e31cf7f97` extends the repository Security Notes verifier from plans to traceability records; `0185267ab819dd4b9ac1352f5fce1df8e2a7a782` adds the required Project Persistence security boundary. +- Governance RED `45037f9fe5aa7c265d28c0da33fdedcf26f3ac49` proves a later peer section cannot supply missing Security Notes evidence and requires the project-format document to use the canonical heading. `907f3a7f70db0ac9fce11839ee215600cec15abe` bounds extraction at the next same-level heading; `65e422cd4d39014f2586ca86616f25b4b17e5e82` adds the nested-heading case and `d1ba145d9cdd7126df240a02d0c07f253a80d3c3` closes the remaining parent-heading escape without excluding legitimate nested subsections. `3883f342ac427fbed35fe2a88c4c6e7dd2f6a499` keeps the verifier Ruff import ordering canonical. - `a7c86be8e20895e3baebee44d33ef765e0837b5f` requires the buyer-visible limit to name the exact `5 * 1024 * 1024` ceiling as 5 MiB. `04e19ef6d19aced87e22015e4ec165cbce89f1d0` fixes the native diagnostic and `73d6a80183c19166b75be05f9286bee3769069e0` aligns the engineering format documentation without changing the byte threshold. - Later project-format work preserves these shared-song rules while advancing current writes to version 3. `ace91a29e540919d02716dd492e290f9743422a8` repairs stale v2-output assertions so legacy/v1/v2 remain predecessor compatibility inputs rather than being mistaken for current output. @@ -33,6 +34,7 @@ The shared renderer authority is `packages/shared-types/src/index.ts` on protect - **Clamp invalid practice progress:** silent coercion hides corruption or contract drift. - **Treat explicit `null` as omission:** the renderer does not, so doing so natively creates cross-language disagreement. - **Keep `5MB` for a binary ceiling:** 5 × 1024 × 1024 bytes is 5 MiB; buyer-visible diagnostics must name the actual unit. +- **Search for Security Notes keywords until end-of-file:** unrelated later sections could make an incomplete security record pass the verifier. Extraction must respect Markdown section hierarchy. ## Current effect @@ -40,33 +42,35 @@ A current shared rehearsal song crosses Project Persistence without dropping the Current `.bscope` writes are now `projectFormatVersion: 3`, not v1. V3 retains the closed stable playback preference and adds an optional path-free app-owned `sourceReference`; legacy raw-song, v1, and v2 inputs migrate deterministically without inventing source evidence. The source-reference schema is separate from shared-song MIR/rehearsal truth. +The Security Notes verifier now treats the requested heading as a real Markdown section: nested subsections remain inside it, while the next peer or parent heading terminates the evidence window. A later Operations/Decision section therefore cannot satisfy missing mitigation/test/risk requirements by keyword coincidence. + Transcription-number semantics still require an evidence-driven cross-language contract: the shared validator currently type-checks `onset`, `offset`, and `velocity` as JavaScript numbers rather than defining rehearsal-specific numeric bounds, so persistence must not invent such bounds without product/scientific evidence. ## Security Notes ### Attack surface -`.bscope` content is untrusted local file input. Save targets, recovery journals, staged/backup/displaced files, file metadata, collaboration payloads, role-level rehearsal data, renderer project JSON, and the optional app-owned source reference cross trust boundaries. This remains local-first and adds no network authority. +`.bscope` content is untrusted local file input. Save targets, recovery journals, staged/backup/displaced files, file metadata, collaboration payloads, role-level rehearsal data, renderer project JSON, and the optional app-owned source reference cross trust boundaries. Documentation evidence itself is also a governance input: a permissive parser could misclassify incomplete Security Notes as compliant. This remains local-first and adds no network authority. ### Trust boundary -Native Project Persistence is the durable storage authority. It admits the versioned envelope, applies `deny_unknown_fields`, validates finite-positive tempo and closed domains, rejects explicit `null` where omission is required, and keeps volatile playback capabilities and user paths out of durable truth. Resource Admission—not Project Persistence—owns the future derivation/re-admission of an app-owned audio artifact from a validated v3 source reference. +Native Project Persistence is the durable storage authority. It admits the versioned envelope, applies `deny_unknown_fields`, validates finite-positive tempo and closed domains, rejects explicit `null` where omission is required, and keeps volatile playback capabilities and user paths out of durable truth. Resource Admission—not Project Persistence—owns the future derivation/re-admission of an app-owned audio artifact from a validated v3 source reference. The repository verifier owns only documentation-policy evidence and must not infer required content from outside the actual Security Notes section. ### Mitigations -Typed allowlists are used instead of arbitrary JSON/string bags. Reads are bounded to 5 MiB and use no-follow/native-identity checks. Saves stage and sync complete bytes before publication, preserve data-file permissions without executable/special bits, and use target-scoped recovery journals plus parent-directory synchronization. Current source references are path-free and limited to a BandScope project id, fixed `source.` artifact name, admitted extension, and positive byte evidence; malformed references fail before publication. +Typed allowlists are used instead of arbitrary JSON/string bags. Reads are bounded to 5 MiB and use no-follow/native-identity checks. Saves stage and sync complete bytes before publication, preserve data-file permissions without executable/special bits, and use target-scoped recovery journals plus parent-directory synchronization. Current source references are path-free and limited to a BandScope project id, fixed `source.` artifact name, admitted extension, and positive byte evidence; malformed references fail before publication. Security Notes extraction stops at the next heading whose level is the same as or higher than the Security Notes heading, preserving legitimate nested subsections while excluding unrelated later evidence. ### Safe failure and logging/privacy -Malformed/unsupported envelopes, invalid shared-domain tokens, explicit-null drift, unsafe paths, source-reference mismatch, identity mismatch, oversized files, and ambiguous recovery state return bounded product errors without echoing project content, local paths, collaboration text, credentials, or secret-shaped values. Failure must retain known-good data or retryable recovery state once mutation begins; it must not coerce corrupt values, fabricate source evidence, or fall back to direct overwrite. +Malformed/unsupported envelopes, invalid shared-domain tokens, explicit-null drift, unsafe paths, source-reference mismatch, identity mismatch, oversized files, and ambiguous recovery state return bounded product errors without echoing project content, local paths, collaboration text, credentials, or secret-shaped values. Failure must retain known-good data or retryable recovery state once mutation begins; it must not coerce corrupt values, fabricate source evidence, or fall back to direct overwrite. A malformed or incomplete documentation section fails verification instead of borrowing keywords from later content. ### Test points -Executable coverage includes shared-song parse/serialize parity, closed-domain positive/negative cases, omission-versus-null behavior, progress bounds, legacy/v1/v2 migration, v3 source-reference round trip/rejection, exact 5 MiB diagnostics, symlink/reparse and ancestor checks, native file identity, first-save/no-clobber behavior, existing-target replacement, stage cleanup, permission normalization, Windows replacement/recovery, macOS/Windows case-alias recovery, completed rollback, stale-journal cleanup, and passive renderer object admission. `scripts/checks/verify_security_notes.py` treats traceability records as governed Security Notes documents. +Executable coverage includes shared-song parse/serialize parity, closed-domain positive/negative cases, omission-versus-null behavior, progress bounds, legacy/v1/v2 migration, v3 source-reference round trip/rejection, exact 5 MiB diagnostics, symlink/reparse and ancestor checks, native file identity, first-save/no-clobber behavior, existing-target replacement, stage cleanup, permission normalization, Windows replacement/recovery, macOS/Windows case-alias recovery, completed rollback, stale-journal cleanup, and passive renderer object admission. `scripts/checks/verify_security_notes.py` treats traceability records as governed Security Notes documents; regression coverage proves both peer-heading and parent-heading boundaries and the canonical `## Security Notes` heading in the project-format document. ### Realistic threats -Relevant threats are malformed/future project payloads being treated as current truth; a local directory participant racing or pre-creating recovery names; link/reparse redirection; file replacement between preflight and publication; interruption during replacement/rollback; permissive modes exposing rehearsal data to another local account; executable renderer object shapes crossing the adapter; and stale playback/user-path authority being persisted as project truth. These controls do not claim protection against a fully compromised OS or attacker with equivalent account authority. +Relevant threats are malformed/future project payloads being treated as current truth; a local directory participant racing or pre-creating recovery names; link/reparse redirection; file replacement between preflight and publication; interruption during replacement/rollback; permissive modes exposing rehearsal data to another local account; executable renderer object shapes crossing the adapter; stale playback/user-path authority being persisted as project truth; and incomplete security documentation being accepted because required words appear later in an unrelated section. These controls do not claim protection against a fully compromised OS or attacker with equivalent account authority. ### Remaining risk From 3eabb08041136432c191195f3e698b1c85f202fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:31:05 +0900 Subject: [PATCH 267/448] test(project): preserve unrelated rollback artifact --- .../project_persistence_rollback_identity.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs diff --git a/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs new file mode 100644 index 000000000..3f49d8518 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_rollback_identity.rs @@ -0,0 +1,93 @@ +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod project_persistence { + include!("../src/project_persistence.rs"); + + pub(crate) fn create_publication_journal_for_test( + target: &std::path::Path, + candidate_stage: &std::path::Path, + displaced: &std::path::Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, + ) -> Result { + create_publication_journal(target, candidate_stage, displaced, expected, candidate) + } + + pub(crate) fn finish_rolled_back_publication_for_test( + stage: &std::path::Path, + journal: &std::path::Path, + target: &std::path::Path, + ) -> Result<(), String> { + finish_rolled_back_publication(stage, journal, target) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod rollback_identity { + use super::project_persistence; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-rollback-identity-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + #[test] + fn rollback_cleanup_preserves_a_stage_that_is_no_longer_the_candidate() { + let root = test_dir("foreign-stage"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let original = br#"{"id":"original"}"#; + let candidate = br#"{"id":"candidate"}"#; + let foreign = br#"{"id":"foreign-racer"}"#; + fs::write(&target, original).expect("original fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = project_persistence::project_file_identity(&target) + .expect("original identity should be capturable"); + let candidate_identity = project_persistence::project_file_identity(&stage) + .expect("candidate identity should be capturable"); + let journal = project_persistence::create_publication_journal_for_test( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("prepared rollback journal should be durable"); + + fs::remove_file(&stage).expect("candidate pathname should be replaceable by the race fixture"); + fs::write(&stage, foreign).expect("foreign rollback artifact should be written"); + + let error = project_persistence::finish_rolled_back_publication_for_test( + &stage, + &journal, + &target, + ) + .expect_err("rollback cleanup must not delete a stage whose identity no longer matches the candidate"); + + assert_eq!(error, "Could not recover the project publication safely."); + assert_eq!( + fs::read(&stage).expect("foreign artifact must remain for recovery"), + foreign + ); + assert!(journal.exists(), "the journal must remain when rollback identity is ambiguous"); + assert_eq!( + fs::read(&target).expect("target must remain untouched"), + original + ); + + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} From a93626e11406b68fc1eac3999d3ee05b300d6da6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:36:51 +0900 Subject: [PATCH 268/448] fix(project): preserve mismatched rollback artifacts --- .../src-tauri/src/project_persistence.rs | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index ec56f3e75..108723452 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -697,6 +697,21 @@ fn finish_rolled_back_publication( journal: &Path, target: &Path, ) -> Result<(), String> { + let journal_content = read_project_file_with_opener( + journal, + open_project_file, + MAX_RECOVERY_JOURNAL_BYTES, + PROJECT_RECOVERY_ERROR, + ) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let durable_journal: PublicationJournal = serde_json::from_str(&journal_content) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let stage_identity = + project_file_identity(stage).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if stage_identity != durable_journal.candidate { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + sync_parent_directory(project_parent(target)) .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; remove_recovery_artifact(stage)?; @@ -1372,7 +1387,7 @@ mod tests { let root = test_dir("max-name"); let target = root.join("a".repeat(255)); - publish_new_project_file(&target, br#"{"id":"song-1"}"#) + publish_new_project_file(&target, br#"{\"id\":\"song-1\"}"#) .expect("a max-length target name should still be stageable"); assert!(target.is_file()); @@ -1388,8 +1403,8 @@ mod tests { 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"}"#; + 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"); @@ -1432,9 +1447,9 @@ mod tests { } else { stage.clone() }; - let original = br#"{"id":"original"}"#; - let candidate = br#"{"id":"candidate"}"#; - let competing = br#"{"id":"competing"}"#; + 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) @@ -1531,7 +1546,7 @@ mod tests { 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"}"#; + let content = r#"{\"id\":\"song-1\"}"#; fs::write(&target, content).expect("fixture should be written"); assert_eq!( @@ -1549,14 +1564,14 @@ mod tests { 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"); + 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"); + fs::remove_dir_all(root).expect("test fixture should be removable"); } #[test] @@ -1565,8 +1580,8 @@ mod tests { 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"}"#) + 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| { @@ -1593,7 +1608,7 @@ mod tests { .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"); + fs::remove_dir_all(root).expect("test fixture should be removable"); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1602,8 +1617,8 @@ mod tests { 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"}"#; + 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"); @@ -1636,9 +1651,9 @@ mod tests { 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"}"#; + 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"); @@ -1676,8 +1691,8 @@ mod tests { 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"}"#; + 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"); @@ -1713,7 +1728,7 @@ mod tests { 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(&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"), @@ -1725,7 +1740,7 @@ mod tests { .expect("an unrelated incomplete journal must not block recovery"); assert_eq!( fs::read(&target).expect("target should remain readable"), - br#"{"id":"selected"}"# + br#"{\"id\":\"selected\"}"# ); fs::remove_dir_all(root).expect("fixture directory should be removable"); } From 9816fe44567427651211997f98a1521ce2a03e8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:07:21 +0900 Subject: [PATCH 269/448] test(audio): preserve approved native intake errors --- apps/desktop/src/lib/analysis.test.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..06b804b20 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -4,6 +4,7 @@ import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + selectLocalAudioSource, startAnalysisJob } from "./analysis"; @@ -99,6 +100,37 @@ describe("analysis bridge", () => { expect(selection.ok).toBe(true); }); + it.each([ + "Could not read the selected audio file.", + "Could not prepare the local project workspace.", + "Could not prepare the local cache workspace.", + "Could not prepare the local temp workspace." + ])("preserves an approved native local-audio string error: %s", async (message) => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(message); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message + } + }); + }); + + it("redacts an unapproved native local-audio string error", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue("Could not read /Users/example/Music/private-demo.wav"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + it("normalizes legacy analysis job status responses before returning them", async () => { const legacyResult = createDemoRehearsalSong() as unknown as { sections: Array>; From 04e813eb928ac057147d2a5438e3fd0f699a8b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:09:29 +0900 Subject: [PATCH 270/448] fix(audio): preserve safe native intake diagnostics --- apps/desktop/src/lib/analysis.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 5a1537684..2fc050f88 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -225,6 +225,14 @@ async function invokeAnalysis(command: string, args?: Record): return browserFallback(command, args); } +/** Preserve only the bounded native intake messages approved for buyer-visible diagnostics. */ +function localAudioErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; + return message && SAFE_LOCAL_AUDIO_MESSAGES.has(message) + ? message + : UNSUPPORTED_LOCAL_AUDIO_MESSAGE; +} + /** Documented. */ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); @@ -243,10 +251,7 @@ export async function selectLocalAudioSource(): Promise Date: Sun, 6 Sep 2026 04:19:22 +0900 Subject: [PATCH 271/448] test(audio): bind native intake diagnostics to resource owner --- apps/desktop/src/lib/analysis.test.ts | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index 62170fd23..4fb47f211 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -15,6 +15,7 @@ type TauriWindow = Window & { }; const tauriWindow = window as TauriWindow; +const OVERSIZED_LOCAL_AUDIO_NEXT_ACTION = "Choose a shorter or smaller song file to start analysis."; describe("analysis bridge", () => { beforeEach(() => { @@ -43,7 +44,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Selected audio file exceeds the 100 MiB analysis limit." + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION } }); }); @@ -69,7 +70,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Selected audio file exceeds the 100 MiB analysis limit." + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION } }); }); @@ -153,6 +154,37 @@ describe("analysis bridge", () => { expect(selection.ok).toBe(true); }); + it.each([ + "Could not read the selected audio file.", + "Could not prepare the local project workspace.", + "Could not prepare the local cache workspace.", + "Could not prepare the local temp workspace." + ])("preserves an approved native local-audio string error: %s", async (message) => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(message); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message + } + }); + }); + + it("redacts an unapproved native local-audio string error", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue("Could not read /Users/example/Music/private-demo.wav"); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + it("normalizes legacy analysis job status responses before returning them", async () => { const legacyResult = createDemoRehearsalSong() as unknown as { sections: Array>; From 804a2867e877947feaffb1da6c6072e6a49049fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:33:19 +0900 Subject: [PATCH 272/448] test(audio): reject growth while materializing admitted source --- apps/desktop/core/src/audio_resource.rs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index 8c47456aa..f3fbea4b6 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -20,3 +20,33 @@ pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result Date: Sun, 6 Sep 2026 04:33:38 +0900 Subject: [PATCH 273/448] fix(audio): bound admitted source materialization --- apps/desktop/core/src/audio_resource.rs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index f3fbea4b6..4c4bd74e1 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -1,3 +1,5 @@ +use std::io::{Read, Write}; + /// Maximum encoded local-audio file size accepted by the desktop bootstrap boundary. pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; @@ -21,6 +23,36 @@ pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result( + reader: R, + writer: &mut W, + max_bytes: u64, +) -> Result { + let mut bounded_reader = reader.take(max_bytes.saturating_add(1)); + let copied = std::io::copy(&mut bounded_reader, writer) + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + if copied == 0 { + return Err(LOCAL_AUDIO_READ_ERROR.to_string()); + } + if copied > max_bytes { + return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); + } + Ok(copied) +} + +/// Copy one admitted local-audio stream into a staging writer without allowing +/// source growth to exceed the encoded-byte resource ceiling. +/// +/// Security Notes: callers must pass an already-open, OS-authorized source +/// descriptor and a private app-owned staging writer. The helper reads at most +/// one byte beyond the 100 MiB ceiling so growth after metadata admission is +/// detected without allocating or copying an unbounded source. The caller must +/// discard the staging artifact on error and publish it only after this method +/// returns the observed byte count successfully. +pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> Result { + copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES) +} + #[cfg(test)] mod tests { use super::*; From a2b1bd9e33a69be75f813f005abd37345200ce55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:45:27 +0900 Subject: [PATCH 274/448] fix(audio): materialize selected source into project storage --- apps/desktop/src-tauri/src/main.rs | 68 +++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1a78bfbf3..994f55418 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -141,7 +141,19 @@ fn app_owned_root( Ok(root) } -fn normalize_local_audio_source(path: &Path) -> Result { +/// Admit one OS-selected local audio file into a project-owned immutable source artifact. +/// +/// Security Notes: the external path is used only to canonicalize and open the +/// user-authorized source. Size is checked from that opened descriptor, bytes +/// are copied through the bounded Resource Admission helper into a private +/// same-project staging file, and only a successful flushed stage is renamed to +/// `source.`. Bootstrap state therefore points at app-owned bytes; +/// a later mutation, move, permission change, or replacement of the user's +/// original path cannot change the bytes submitted to analysis. +fn materialize_local_audio_source( + path: &Path, + project_root: &Path, +) -> Result { let canonical = path .canonicalize() .map_err(|_| "Could not read the selected audio file.".to_string())?; @@ -153,20 +165,56 @@ fn normalize_local_audio_source(path: &Path) -> Result file_size_bytes, + Err(error) => { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err(error); + } + }; + if staged.sync_all().is_err() { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + drop(staged); + + if destination.exists() { + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + if std::fs::rename(&stage, &destination).is_err() { + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: file_name.to_string(), + source_path: destination.to_string_lossy().into_owned(), + file_name, extension, file_size_bytes, }) @@ -643,11 +691,11 @@ fn select_local_audio_source( .add_filter("Audio", &AUDIO_EXTENSIONS) .pick_file() .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?; - let source = normalize_local_audio_source(&path)?; let project_id = next_project_id(&state); let project_root = app_owned_root(&app, "projects", &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; let temp_root = app_owned_root(&app, "temp", &project_id)?; + let source = materialize_local_audio_source(&path, &project_root)?; let summary = ProjectBootstrapSummaryPayload { project_id, From 0ee15f02618f2032ad510b965731f909b410a791 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:46:46 +0900 Subject: [PATCH 275/448] docs(audio): trace app-owned source materialization --- .../local-audio-source-materialization.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/doctoring/local-audio-source-materialization.md diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md new file mode 100644 index 000000000..5ebf2d290 --- /dev/null +++ b/docs/doctoring/local-audio-source-materialization.md @@ -0,0 +1,55 @@ +# Local audio source materialization + +## Problem + +The desktop intake boundary previously validated an OS-selected local audio file, then stored the canonical external filesystem path in `ProjectBootstrapSummaryPayload`. Analysis could therefore reopen bytes from a path the application did not own after the original metadata admission. A source could be moved, replaced, truncated, or grown between selection and analysis, and process restart could not reconstruct a trustworthy full-mix source from app-owned project state. + +Issue #962 now makes durable local-source re-admission a Project Persistence prerequisite. That satisfies the existing application-security condition that copying selected media is justified when persistence requirements require an additional storage boundary. Resource Admission & Decode owns creation of that app-owned audio artifact; Project Persistence owns only the versioned reference and migration contract that consumes it. + +## Constraints + +- Local analysis remains local-first and introduces no network or generic filesystem capability. +- The renderer must not choose an arbitrary path for analysis or persistence. +- The encoded-byte ceiling remains 100 MiB. +- An initial metadata length is not sufficient evidence if the selected file changes while it is being admitted. +- The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. +- The change must not claim that project reopen, SHA-256 identity, YouTube source persistence, power-loss recovery, or commercial decoder licensing is already complete. + +## Alternatives + +1. Keep the canonical external path and revalidate immediately before every analysis. Rejected because process restart still depends on mutable external authority and durable project references remain non-portable. +2. Persist the absolute external path in the `.bscope` document. Rejected because #962 explicitly separates portable project identity from arbitrary host paths and because it widens disclosure and authority. +3. Copy the selected local file into the project root after native admission. Selected. It produces the stable `source.` artifact expected by the Project Persistence source-reference contract without allowing the renderer to mint filesystem authority. + +## Implementation and exact evidence + +- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` as an ordinary second parent with no force push. The Resource Admission semantic delta remains a descendant of the current protected base. +- RED `804a2867e877947feaffb1da6c6072e6a49049fe` added bounded-copy regressions for exact-limit acceptance and one-byte-over growth rejection. +- Core fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` added `copy_bounded_local_audio`, which reads at most the configured ceiling plus one byte and returns the observed byte count. +- Native integration `a2b1bd9e33a69be75f813f005abd37345200ce55` now creates the project root before source admission, opens the OS-selected source natively, stages bytes under that project root, flushes the staged file, and publishes `source.` only after bounded copy succeeds. `ProjectBootstrapSummaryPayload.source.sourcePath` now points at the app-owned artifact for local-file intake. + +## Security Notes + +### Untrusted inputs and trust boundaries + +The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. + +### Validation and safe failure + +The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy reads at most 100 MiB plus one byte, so growth after the initial metadata check fails closed without unbounded allocation or copying. A unique private stage is removed on copy or flush failure. The final source artifact is not published until the staged file has been flushed successfully. Errors remain bounded product messages and do not include the original local path. + +### Logging and privacy + +No new logging, telemetry, network transfer, or path exposure is introduced. The original filename remains a user-facing label already present in the bootstrap contract; the original absolute path is no longer the local-analysis source path after successful admission. + +### Test points + +- exact encoded-byte limit remains accepted; +- one-byte-over growth while copying is rejected; +- empty source remains rejected; +- failed copy or flush does not publish the final project-owned source artifact; +- hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. + +## Remaining risks and follow-up + +This slice is not the complete restart/reopen contract. Resource Admission still needs a streaming content identity receipt, preferably SHA-256 computed from the same admitted bytes, so #970/#962 can persist `project_id + artifact_name + extension + observed byte count + digest` without trusting renderer-generated evidence. Reopen must then resolve only the app-owned artifact, revalidate observed size and digest, and mint fresh playback authority. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. From 323a7fac00c4954af12b382802a9d6f8359ef4c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:48:37 +0900 Subject: [PATCH 276/448] fix(audio): export bounded source materialization port --- apps/desktop/core/src/root.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 125d13daa..10cf81f4c 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -10,6 +10,8 @@ mod runtime_core; mod audio_resource; mod score_pdf; -pub use audio_resource::{validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES}; +pub use audio_resource::{ + copy_bounded_local_audio, validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES, +}; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; From dcb3b25606e8eac49299281f187a34c142886d16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:50:36 +0900 Subject: [PATCH 277/448] docs(audio): record native port export repair --- docs/doctoring/local-audio-source-materialization.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index 5ebf2d290..75e3097e4 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -26,7 +26,8 @@ Issue #962 now makes durable local-source re-admission a Project Persistence pre - `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` as an ordinary second parent with no force push. The Resource Admission semantic delta remains a descendant of the current protected base. - RED `804a2867e877947feaffb1da6c6072e6a49049fe` added bounded-copy regressions for exact-limit acceptance and one-byte-over growth rejection. - Core fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` added `copy_bounded_local_audio`, which reads at most the configured ceiling plus one byte and returns the observed byte count. -- Native integration `a2b1bd9e33a69be75f813f005abd37345200ce55` now creates the project root before source admission, opens the OS-selected source natively, stages bytes under that project root, flushes the staged file, and publishes `source.` only after bounded copy succeeds. `ProjectBootstrapSummaryPayload.source.sourcePath` now points at the app-owned artifact for local-file intake. +- Native integration `a2b1bd9e33a69be75f813f005abd37345200ce55` creates the project root before source admission, opens the OS-selected source natively, stages bytes under that project root, flushes the staged file, and publishes `source.` only after bounded copy succeeds. `ProjectBootstrapSummaryPayload.source.sourcePath` now points at the app-owned artifact for local-file intake. +- Source review of that integration found that the new core port was public inside `audio_resource.rs` but not re-exported from the crate root consumed by Tauri. `323a7fac00c4954af12b382802a9d6f8359ef4c5` is the minimal causal repair: it re-exports `copy_bounded_local_audio` without changing the admission policy or widening authority. Hosted evidence must be reacquired on the final descendant rather than transferred from either predecessor. ## Security Notes @@ -48,6 +49,7 @@ No new logging, telemetry, network transfer, or path exposure is introduced. The - one-byte-over growth while copying is rejected; - empty source remains rejected; - failed copy or flush does not publish the final project-owned source artifact; +- Tauri must compile against the exported Resource Admission port; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. ## Remaining risks and follow-up From 131d6d7220985abd207559e6eb5dc122ac989cf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:01:01 +0900 Subject: [PATCH 278/448] test(audio): expose bounded-copy destination error --- apps/desktop/core/src/audio_resource.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index 4c4bd74e1..d9f41e5a1 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -56,7 +56,19 @@ pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; + use std::io::{Cursor, Error, ErrorKind}; + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(Error::new(ErrorKind::Other, "simulated destination failure")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } #[test] fn bounded_copy_rejects_stream_growth_past_the_admitted_limit() { @@ -81,4 +93,15 @@ mod tests { assert_eq!(copied, 4); assert_eq!(staged, vec![1_u8, 2, 3, 4]); } + + #[test] + fn bounded_copy_reports_destination_failure_as_workspace_failure() { + let input = Cursor::new(vec![1_u8, 2, 3, 4]); + let mut staged = FailingWriter; + + let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect_err("a staging write failure must not be reported as a source read failure"); + + assert_eq!(error, "Could not prepare the local project workspace."); + } } From ac4adfdb5df82f48aadd5e028433e3336d3ce2ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:01:30 +0900 Subject: [PATCH 279/448] fix(audio): distinguish bounded-copy destination failure --- apps/desktop/core/src/audio_resource.rs | 74 ++++++++++++++++++++----- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index d9f41e5a1..c61c1bc4f 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -4,6 +4,7 @@ use std::io::{Read, Write}; pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; const LOCAL_AUDIO_READ_ERROR: &str = "Could not read the selected audio file."; +const LOCAL_AUDIO_WRITE_ERROR: &str = "Could not prepare the local project workspace."; const LOCAL_AUDIO_TOO_LARGE_ERROR: &str = "Choose a shorter or smaller song file to start analysis."; @@ -24,19 +25,41 @@ pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result( - reader: R, + mut reader: R, writer: &mut W, max_bytes: u64, ) -> Result { - let mut bounded_reader = reader.take(max_bytes.saturating_add(1)); - let copied = std::io::copy(&mut bounded_reader, writer) - .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + + loop { + if copied == max_bytes { + let mut overflow_probe = [0_u8; 1]; + let read = reader + .read(&mut overflow_probe) + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + if read == 0 { + break; + } + return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); + } + + let remaining = (max_bytes - copied).min(buffer.len() as u64) as usize; + let read = reader + .read(&mut buffer[..remaining]) + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + if read == 0 { + break; + } + writer + .write_all(&buffer[..read]) + .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; + copied += read as u64; + } + if copied == 0 { return Err(LOCAL_AUDIO_READ_ERROR.to_string()); } - if copied > max_bytes { - return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string()); - } Ok(copied) } @@ -44,11 +67,12 @@ fn copy_bounded_local_audio_with_limit( /// source growth to exceed the encoded-byte resource ceiling. /// /// Security Notes: callers must pass an already-open, OS-authorized source -/// descriptor and a private app-owned staging writer. The helper reads at most -/// one byte beyond the 100 MiB ceiling so growth after metadata admission is -/// detected without allocating or copying an unbounded source. The caller must -/// discard the staging artifact on error and publish it only after this method -/// returns the observed byte count successfully. +/// descriptor and a private app-owned staging writer. The helper writes no more +/// than the 100 MiB ceiling and, after reaching it exactly, reads only one probe +/// byte to detect source growth. Source-read and destination-write failures use +/// distinct bounded product errors so storage failures are not misdiagnosed as +/// bad media. The caller must discard the staging artifact on error and publish +/// it only after this method returns the observed byte count successfully. pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> Result { copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES) } @@ -70,8 +94,16 @@ mod tests { } } + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(Error::new(ErrorKind::Other, "simulated source failure")) + } + } + #[test] - fn bounded_copy_rejects_stream_growth_past_the_admitted_limit() { + fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() { let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]); let mut staged = Vec::new(); @@ -79,7 +111,7 @@ mod tests { .expect_err("a source that grows beyond the admitted byte limit must fail closed"); assert_eq!(error, LOCAL_AUDIO_TOO_LARGE_ERROR); - assert_eq!(staged, vec![1_u8, 2, 3, 4, 5]); + assert_eq!(staged, vec![1_u8, 2, 3, 4]); } #[test] @@ -102,6 +134,18 @@ mod tests { let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) .expect_err("a staging write failure must not be reported as a source read failure"); - assert_eq!(error, "Could not prepare the local project workspace."); + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + } + + #[test] + fn bounded_copy_keeps_source_failure_distinct_from_workspace_failure() { + let input = FailingReader; + let mut staged = Vec::new(); + + let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect_err("a source read failure must retain the media-read diagnosis"); + + assert_eq!(error, LOCAL_AUDIO_READ_ERROR); + assert!(staged.is_empty()); } } From e2257d9491d97ff64915af4503563a70c6bd0ea4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:02:58 +0900 Subject: [PATCH 280/448] docs(audio): record bounded-copy diagnostics repair --- .../local-audio-source-materialization.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index 75e3097e4..eb2816aef 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -6,12 +6,15 @@ The desktop intake boundary previously validated an OS-selected local audio file Issue #962 now makes durable local-source re-admission a Project Persistence prerequisite. That satisfies the existing application-security condition that copying selected media is justified when persistence requirements require an additional storage boundary. Resource Admission & Decode owns creation of that app-owned audio artifact; Project Persistence owns only the versioned reference and migration contract that consumes it. +A later review found a narrower diagnostics defect in the bounded-copy port. `std::io::copy` reports both reader and writer failures through one `io::Error`, and the predecessor mapped every such error to `Could not read the selected audio file.` A full or failing app-owned destination could therefore be reported as corrupt/unreadable source media. The same implementation also wrote the one-byte overflow probe into the disposable stage before rejecting an over-limit source. Neither behavior widened published authority, but both weakened failure diagnosis and the stated encoded-byte staging boundary. + ## Constraints - Local analysis remains local-first and introduces no network or generic filesystem capability. - The renderer must not choose an arbitrary path for analysis or persistence. - The encoded-byte ceiling remains 100 MiB. - An initial metadata length is not sufficient evidence if the selected file changes while it is being admitted. +- Source-read failures and app-owned destination-write failures must remain distinguishable without exposing paths or OS error details. - The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. - The change must not claim that project reopen, SHA-256 identity, YouTube source persistence, power-loss recovery, or commercial decoder licensing is already complete. @@ -20,14 +23,19 @@ Issue #962 now makes durable local-source re-admission a Project Persistence pre 1. Keep the canonical external path and revalidate immediately before every analysis. Rejected because process restart still depends on mutable external authority and durable project references remain non-portable. 2. Persist the absolute external path in the `.bscope` document. Rejected because #962 explicitly separates portable project identity from arbitrary host paths and because it widens disclosure and authority. 3. Copy the selected local file into the project root after native admission. Selected. It produces the stable `source.` artifact expected by the Project Persistence source-reference contract without allowing the renderer to mint filesystem authority. +4. Keep `std::io::copy` and surface one generic copy error. Rejected because it cannot distinguish an untrusted source read failure from failure to write BandScope-owned project storage. A bounded explicit read/write loop preserves the same byte ceiling while keeping those trust-boundary failures separate. ## Implementation and exact evidence - `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` as an ordinary second parent with no force push. The Resource Admission semantic delta remains a descendant of the current protected base. - RED `804a2867e877947feaffb1da6c6072e6a49049fe` added bounded-copy regressions for exact-limit acceptance and one-byte-over growth rejection. -- Core fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` added `copy_bounded_local_audio`, which reads at most the configured ceiling plus one byte and returns the observed byte count. +- Core fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` added `copy_bounded_local_audio` and the 100 MiB admission boundary. - Native integration `a2b1bd9e33a69be75f813f005abd37345200ce55` creates the project root before source admission, opens the OS-selected source natively, stages bytes under that project root, flushes the staged file, and publishes `source.` only after bounded copy succeeds. `ProjectBootstrapSummaryPayload.source.sourcePath` now points at the app-owned artifact for local-file intake. -- Source review of that integration found that the new core port was public inside `audio_resource.rs` but not re-exported from the crate root consumed by Tauri. `323a7fac00c4954af12b382802a9d6f8359ef4c5` is the minimal causal repair: it re-exports `copy_bounded_local_audio` without changing the admission policy or widening authority. Hosted evidence must be reacquired on the final descendant rather than transferred from either predecessor. +- Source review of that integration found that the new core port was public inside `audio_resource.rs` but not re-exported from the crate root consumed by Tauri. `323a7fac00c4954af12b382802a9d6f8359ef4c5` is the minimal causal repair: it re-exports `copy_bounded_local_audio` without changing the admission policy or widening authority. +- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` requires a failing staging writer to produce the bounded workspace error rather than the source-media read error. +- Causal fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` replaces the ambiguous `std::io::copy` mapping with an explicit bounded read/write loop. It writes at most 100 MiB, performs a one-byte read-only probe after reaching the ceiling, preserves the media-read error for reader failures, and maps writer failures to the existing app-owned workspace error. It also adds the inverse reader-failure regression so the two diagnoses cannot collapse again. + +Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. ## Security Notes @@ -37,7 +45,7 @@ The selected audio path, file metadata, and media bytes remain untrusted. The OS ### Validation and safe failure -The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy reads at most 100 MiB plus one byte, so growth after the initial metadata check fails closed without unbounded allocation or copying. A unique private stage is removed on copy or flush failure. The final source artifact is not published until the staged file has been flushed successfully. Errors remain bounded product messages and do not include the original local path. +The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written to project storage. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. Neither exposes the source path, destination path, raw OS error, or media contents. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. ### Logging and privacy @@ -46,8 +54,10 @@ No new logging, telemetry, network transfer, or path exposure is introduced. The ### Test points - exact encoded-byte limit remains accepted; -- one-byte-over growth while copying is rejected; +- one-byte-over growth is rejected without staging the probe byte; - empty source remains rejected; +- reader failure retains the bounded selected-audio diagnosis; +- destination writer failure reports the bounded app-owned workspace diagnosis; - failed copy or flush does not publish the final project-owned source artifact; - Tauri must compile against the exported Resource Admission port; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. From dc413794fb84c736085ab77b763854ba0f58bdf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:07:52 +0900 Subject: [PATCH 281/448] test(audio): require native content identity receipt --- .../core/tests/local_audio_content_identity.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/desktop/core/tests/local_audio_content_identity.rs diff --git a/apps/desktop/core/tests/local_audio_content_identity.rs b/apps/desktop/core/tests/local_audio_content_identity.rs new file mode 100644 index 000000000..5038c129d --- /dev/null +++ b/apps/desktop/core/tests/local_audio_content_identity.rs @@ -0,0 +1,18 @@ +use bandscope_desktop_core::copy_bounded_local_audio_with_receipt; +use std::io::Cursor; + +#[test] +fn local_audio_copy_receipt_hashes_exact_admitted_bytes() { + let input = vec![1_u8, 2, 3, 4]; + let mut staged = Vec::new(); + + let receipt = copy_bounded_local_audio_with_receipt(Cursor::new(&input), &mut staged) + .expect("bounded admission should return content identity for the bytes it stages"); + + assert_eq!(staged, input); + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); +} From 566cd1f991296e7f3c288cb07a11c2d2effb258a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:09:30 +0900 Subject: [PATCH 282/448] fix(audio): emit streaming content identity receipt --- apps/desktop/core/src/audio_resource.rs | 119 ++++++++++--- apps/desktop/core/src/content_sha256.rs | 214 ++++++++++++++++++++++++ apps/desktop/core/src/root.rs | 4 +- 3 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/core/src/content_sha256.rs diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index c61c1bc4f..0c8a890e1 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -1,4 +1,5 @@ -use std::io::{Read, Write}; +use crate::content_sha256::StreamingSha256; +use std::io::{ErrorKind, Read, Write}; /// Maximum encoded local-audio file size accepted by the desktop bootstrap boundary. pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024; @@ -8,6 +9,15 @@ const LOCAL_AUDIO_WRITE_ERROR: &str = "Could not prepare the local project works const LOCAL_AUDIO_TOO_LARGE_ERROR: &str = "Choose a shorter or smaller song file to start analysis."; +/// Immutable identity evidence for one successfully staged local-audio byte stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalAudioCopyReceipt { + /// Exact number of bytes written successfully to the staging writer. + pub file_size_bytes: u64, + /// SHA-256 of exactly the bytes written successfully, encoded as lowercase hexadecimal. + pub content_sha256: String, +} + /// Validate a native local-audio file length before storing bootstrap metadata. /// /// The caller must obtain this length from the native filesystem descriptor or @@ -24,20 +34,29 @@ pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result Result { + loop { + match reader.read(buffer) { + Ok(read) => return Ok(read), + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(_) => return Err(LOCAL_AUDIO_READ_ERROR.to_string()), + } + } +} + fn copy_bounded_local_audio_with_limit( mut reader: R, writer: &mut W, max_bytes: u64, -) -> Result { +) -> Result { let mut copied = 0_u64; let mut buffer = [0_u8; 64 * 1024]; + let mut content_digest = StreamingSha256::default(); loop { if copied == max_bytes { let mut overflow_probe = [0_u8; 1]; - let read = reader - .read(&mut overflow_probe) - .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + let read = read_retrying_interrupted(&mut reader, &mut overflow_probe)?; if read == 0 { break; } @@ -45,42 +64,62 @@ fn copy_bounded_local_audio_with_limit( } let remaining = (max_bytes - copied).min(buffer.len() as u64) as usize; - let read = reader - .read(&mut buffer[..remaining]) - .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + let read = read_retrying_interrupted(&mut reader, &mut buffer[..remaining])?; if read == 0 { break; } writer .write_all(&buffer[..read]) .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; + content_digest + .update(&buffer[..read]) + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; copied += read as u64; } if copied == 0 { return Err(LOCAL_AUDIO_READ_ERROR.to_string()); } - Ok(copied) + let content_sha256 = content_digest + .finalize_hex() + .map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?; + Ok(LocalAudioCopyReceipt { + file_size_bytes: copied, + content_sha256, + }) } -/// Copy one admitted local-audio stream into a staging writer without allowing -/// source growth to exceed the encoded-byte resource ceiling. +/// Copy one admitted local-audio stream into a staging writer and return native content identity. /// /// Security Notes: callers must pass an already-open, OS-authorized source /// descriptor and a private app-owned staging writer. The helper writes no more -/// than the 100 MiB ceiling and, after reaching it exactly, reads only one probe -/// byte to detect source growth. Source-read and destination-write failures use -/// distinct bounded product errors so storage failures are not misdiagnosed as -/// bad media. The caller must discard the staging artifact on error and publish -/// it only after this method returns the observed byte count successfully. -pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> Result { +/// than the 100 MiB ceiling, hashes exactly the bytes whose writes succeeded, +/// and, after reaching the ceiling exactly, reads only one probe byte to detect +/// source growth. Source-read and destination-write failures use distinct +/// bounded product errors so storage failures are not misdiagnosed as bad media. +/// The caller must discard the staging artifact on error, synchronize it before +/// publication, and bind the returned receipt only to the artifact that was +/// actually published. +pub fn copy_bounded_local_audio_with_receipt( + reader: R, + writer: &mut W, +) -> Result { copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES) } +/// Copy one admitted local-audio stream into a staging writer and return its byte count. +/// +/// This compatibility adapter preserves the existing desktop call boundary while +/// callers migrate to `copy_bounded_local_audio_with_receipt`. It uses the same +/// bounded copy and content-hash path and discards only the returned digest. +pub fn copy_bounded_local_audio(reader: R, writer: &mut W) -> Result { + copy_bounded_local_audio_with_receipt(reader, writer).map(|receipt| receipt.file_size_bytes) +} + #[cfg(test)] mod tests { use super::*; - use std::io::{Cursor, Error, ErrorKind}; + use std::io::{Cursor, Error}; struct FailingWriter; @@ -102,6 +141,21 @@ mod tests { } } + struct InterruptedThenReader { + bytes: Cursor>, + interrupted: bool, + } + + impl Read for InterruptedThenReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(Error::from(ErrorKind::Interrupted)); + } + self.bytes.read(buffer) + } + } + #[test] fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() { let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]); @@ -115,14 +169,18 @@ mod tests { } #[test] - fn bounded_copy_accepts_the_exact_limit_and_reports_observed_bytes() { + fn bounded_copy_accepts_the_exact_limit_and_reports_content_identity() { let input = Cursor::new(vec![1_u8, 2, 3, 4]); let mut staged = Vec::new(); - let copied = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4) .expect("the exact encoded-byte limit remains admissible"); - assert_eq!(copied, 4); + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); assert_eq!(staged, vec![1_u8, 2, 3, 4]); } @@ -148,4 +206,23 @@ mod tests { assert_eq!(error, LOCAL_AUDIO_READ_ERROR); assert!(staged.is_empty()); } + + #[test] + fn bounded_copy_retries_interrupted_source_reads_without_changing_identity() { + let input = InterruptedThenReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4]), + interrupted: false, + }; + let mut staged = Vec::new(); + + let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4) + .expect("an interrupted source read should be retried"); + + assert_eq!(receipt.file_size_bytes, 4); + assert_eq!( + receipt.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + assert_eq!(staged, vec![1_u8, 2, 3, 4]); + } } diff --git a/apps/desktop/core/src/content_sha256.rs b/apps/desktop/core/src/content_sha256.rs new file mode 100644 index 000000000..3c217d58c --- /dev/null +++ b/apps/desktop/core/src/content_sha256.rs @@ -0,0 +1,214 @@ +//! Streaming SHA-256 for local content-identity receipts. +//! +//! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer +//! tests below are correctness checks, not CAVP validation or a FIPS 140 claim. + +const BLOCK_BYTES: usize = 64; +const DIGEST_BYTES: usize = 32; +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, +]; + +#[derive(Clone)] +pub(crate) struct StreamingSha256 { + words: [u32; 8], + buffer: [u8; BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for StreamingSha256 { + fn default() -> Self { + Self { + words: INITIAL_STATE, + buffer: [0; BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl StreamingSha256 { + /// Add the next contiguous admitted byte slice to this digest state. + pub(crate) fn update(&mut self, mut bytes: &[u8]) -> Result<(), ()> { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .ok_or(())?; + + if self.buffer_len != 0 { + let copied = (BLOCK_BYTES - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= BLOCK_BYTES { + let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES].try_into().map_err(|_| ())?; + self.compress(block); + bytes = &bytes[BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + Ok(()) + } + + /// Finalize the digest as canonical lowercase hexadecimal. + pub(crate) fn finalize_hex(mut self) -> Result { + let message_len_bits = self.message_len_bytes.checked_mul(8).ok_or(())?; + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut digest = [0_u8; DIGEST_BYTES]; + for (index, word) in self.words.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + + let mut encoded = String::with_capacity(DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(encoded) + } + + fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { + let mut schedule = [0_u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk + .try_into() + .expect("SHA-256 message word always contains four bytes"), + ); + } + for index in 16..64 { + let small_sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let small_sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(small_sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(small_sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.words; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = big_sigma0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + self.words[0] = self.words[0].wrapping_add(a); + self.words[1] = self.words[1].wrapping_add(b); + self.words[2] = self.words[2].wrapping_add(c); + self.words[3] = self.words[3].wrapping_add(d); + self.words[4] = self.words[4].wrapping_add(e); + self.words[5] = self.words[5].wrapping_add(f); + self.words[6] = self.words[6].wrapping_add(g); + self.words[7] = self.words[7].wrapping_add(h); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest_in_chunks(bytes: &[u8], chunk_size: usize) -> String { + let mut digest = StreamingSha256::default(); + for chunk in bytes.chunks(chunk_size) { + digest.update(chunk).expect("test vector length must fit SHA-256"); + } + digest + .finalize_hex() + .expect("test vector bit length must fit SHA-256") + } + + #[test] + fn matches_sha256_known_answer_vectors() { + for (message, expected) in [ + ( + &b""[..], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + &b"abc"[..], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(digest_in_chunks(message, 7), expected); + } + } + + #[test] + fn matches_the_million_a_vector() { + assert_eq!( + digest_in_chunks(&vec![b'a'; 1_000_000], 64 * 1024), + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0" + ); + } +} diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 10cf81f4c..24f412ab6 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -8,10 +8,12 @@ #[path = "lib.rs"] mod runtime_core; mod audio_resource; +mod content_sha256; mod score_pdf; pub use audio_resource::{ - copy_bounded_local_audio, validate_local_audio_file_size, MAX_LOCAL_AUDIO_FILE_BYTES, + copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, + validate_local_audio_file_size, LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, }; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; From f9556611684c07012aaad843f61da485edaac891 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:10:37 +0900 Subject: [PATCH 283/448] docs(audio): trace native content identity receipt --- .../local-audio-source-materialization.md | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index eb2816aef..d6685f228 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -8,6 +8,8 @@ Issue #962 now makes durable local-source re-admission a Project Persistence pre A later review found a narrower diagnostics defect in the bounded-copy port. `std::io::copy` reports both reader and writer failures through one `io::Error`, and the predecessor mapped every such error to `Could not read the selected audio file.` A full or failing app-owned destination could therefore be reported as corrupt/unreadable source media. The same implementation also wrote the one-byte overflow probe into the disposable stage before rejecting an over-limit source. Neither behavior widened published authority, but both weakened failure diagnosis and the stated encoded-byte staging boundary. +The next persistence handoff exposed a separate evidence gap: the bounded copy returned only a byte count. Project Persistence therefore had no native content identity that proved which admitted bytes were staged. A renderer-generated digest would invert the trust boundary, and hashing a mutable external path later would no longer identify the app-owned artifact that analysis actually consumes. + ## Constraints - Local analysis remains local-first and introduces no network or generic filesystem capability. @@ -15,8 +17,11 @@ A later review found a narrower diagnostics defect in the bounded-copy port. `st - The encoded-byte ceiling remains 100 MiB. - An initial metadata length is not sufficient evidence if the selected file changes while it is being admitted. - Source-read failures and app-owned destination-write failures must remain distinguishable without exposing paths or OS error details. +- Transient `Interrupted` reads must be retried rather than misdiagnosed as unreadable media. +- Content identity must be computed from the exact byte slices whose staging writes succeeded; the one-byte overflow probe is not part of the digest. +- SHA-256 is used only as content-identity evidence. This implementation does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against a malicious actor who can replace both an artifact and its stored digest. - The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. -- The change must not claim that project reopen, SHA-256 identity, YouTube source persistence, power-loss recovery, or commercial decoder licensing is already complete. +- The change must not claim that Tauri already persists the new receipt, project reopen is complete, YouTube source persistence is complete, power-loss recovery is complete, or commercial decoder licensing is solved. ## Alternatives @@ -24,6 +29,8 @@ A later review found a narrower diagnostics defect in the bounded-copy port. `st 2. Persist the absolute external path in the `.bscope` document. Rejected because #962 explicitly separates portable project identity from arbitrary host paths and because it widens disclosure and authority. 3. Copy the selected local file into the project root after native admission. Selected. It produces the stable `source.` artifact expected by the Project Persistence source-reference contract without allowing the renderer to mint filesystem authority. 4. Keep `std::io::copy` and surface one generic copy error. Rejected because it cannot distinguish an untrusted source read failure from failure to write BandScope-owned project storage. A bounded explicit read/write loop preserves the same byte ceiling while keeping those trust-boundary failures separate. +5. Compute the persistence digest in the renderer or later from the original absolute path. Rejected because neither source is authoritative for the bytes successfully staged into BandScope-owned storage. +6. Add a second SHA-256 implementation or a new hashing path in Project Persistence. Rejected. The GUI-independent desktop core is the minimal Shared Kernel for this byte-identity primitive. Active Player's existing local playable-stem SHA-256 implementation must migrate to this canonical primitive when its dependent stack is restacked rather than remain a divergent copy. ## Implementation and exact evidence @@ -34,6 +41,9 @@ A later review found a narrower diagnostics defect in the bounded-copy port. `st - Source review of that integration found that the new core port was public inside `audio_resource.rs` but not re-exported from the crate root consumed by Tauri. `323a7fac00c4954af12b382802a9d6f8359ef4c5` is the minimal causal repair: it re-exports `copy_bounded_local_audio` without changing the admission policy or widening authority. - Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` requires a failing staging writer to produce the bounded workspace error rather than the source-media read error. - Causal fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` replaces the ambiguous `std::io::copy` mapping with an explicit bounded read/write loop. It writes at most 100 MiB, performs a one-byte read-only probe after reaching the ceiling, preserves the media-read error for reader failures, and maps writer failures to the existing app-owned workspace error. It also adds the inverse reader-failure regression so the two diagnoses cannot collapse again. +- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` requires the Resource Admission port to return the exact staged byte count plus the SHA-256 known for bytes `01 02 03 04`. The predecessor has no such receipt API, so this is a genuine missing-contract RED rather than a mock success. +- Causal fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` adds `LocalAudioCopyReceipt` and `copy_bounded_local_audio_with_receipt`. SHA-256 is updated only after the corresponding `write_all` succeeds, so a failed writer never yields identity evidence for an incomplete stage. The compatibility byte-count adapter remains for the current Tauri caller, and `Interrupted` source reads are retried without changing the resulting identity. +- The shared SHA-256 state is in GUI-independent core and is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. These are correctness regressions only; they are not CAVP or module-validation evidence. Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. @@ -41,27 +51,39 @@ Hosted evidence must be reacquired on the final descendant rather than transferr ### Untrusted inputs and trust boundaries -The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. +The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. The content digest is evidence about bytes that successfully crossed that boundary into the staging writer; it is not authorization to reopen an arbitrary host path. ### Validation and safe failure -The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written to project storage. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. Neither exposes the source path, destination path, raw OS error, or media contents. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. +The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written or hashed as admitted content. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. `Interrupted` reads are retried. Neither failure path exposes the source path, destination path, raw OS error, media contents, or a misleading partial digest. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. + +The receipt is currently bound to the byte stream whose writes succeeded, not yet to a post-rename descriptor identity. Tauri must switch from the compatibility byte-count adapter to the receipt API and then verify that the synchronized/published artifact is the same app-owned object before the digest becomes durable `sourceReference` truth. Same-size external mutation of a staging or final artifact remains a threat until that publication binding and reopen verification are complete. ### Logging and privacy -No new logging, telemetry, network transfer, or path exposure is introduced. The original filename remains a user-facing label already present in the bootstrap contract; the original absolute path is no longer the local-analysis source path after successful admission. +No new logging, telemetry, network transfer, or path exposure is introduced. SHA-256 is persisted only as non-secret content identity when the Project Persistence owner consumes the receipt; the current slice does not log it. The original filename remains a user-facing label already present in the bootstrap contract; the original absolute path is no longer the local-analysis source path after successful admission. ### Test points - exact encoded-byte limit remains accepted; -- one-byte-over growth is rejected without staging the probe byte; +- one-byte-over growth is rejected without staging or hashing the probe byte; - empty source remains rejected; - reader failure retains the bounded selected-audio diagnosis; -- destination writer failure reports the bounded app-owned workspace diagnosis; +- transient interrupted reads are retried and preserve the expected digest; +- destination writer failure reports the bounded app-owned workspace diagnosis and cannot return a partial receipt; +- SHA-256 matches authoritative known-answer vectors across short, multi-block, chunked, and one-million-byte inputs; - failed copy or flush does not publish the final project-owned source artifact; -- Tauri must compile against the exported Resource Admission port; +- Tauri must compile against the exported Resource Admission ports; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. ## Remaining risks and follow-up -This slice is not the complete restart/reopen contract. Resource Admission still needs a streaming content identity receipt, preferably SHA-256 computed from the same admitted bytes, so #970/#962 can persist `project_id + artifact_name + extension + observed byte count + digest` without trusting renderer-generated evidence. Reopen must then resolve only the app-owned artifact, revalidate observed size and digest, and mint fresh playback authority. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. +The core can now emit native streaming content identity for exactly the bytes successfully staged, but the current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter and therefore does not yet carry the digest into its bootstrap/persistence handoff. The next owner slice is to consume the receipt at that native caller, bind it to the synchronized and published `source.` artifact, and expose only the path-free identity fields needed by #970/#962. Reopen must then resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. + +The private playable-stem SHA-256 implementation already present in #1160 is now a consolidation finding: once this Resource Admission foundation is available in that stack, #1160 must consume the shared core primitive and delete its local copy rather than maintain two security-sensitive implementations. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://csrc.nist.gov/news/2023/decision-to-revise-fips-180-4 From 373824c7bbb40f2df1bb2721316680378c104834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:13:30 +0900 Subject: [PATCH 284/448] test(core): require reusable SHA-256 reader boundary --- .../desktop/core/tests/content_sha256_shared_kernel.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/desktop/core/tests/content_sha256_shared_kernel.rs diff --git a/apps/desktop/core/tests/content_sha256_shared_kernel.rs b/apps/desktop/core/tests/content_sha256_shared_kernel.rs new file mode 100644 index 000000000..b8d9a0ebd --- /dev/null +++ b/apps/desktop/core/tests/content_sha256_shared_kernel.rs @@ -0,0 +1,10 @@ +use bandscope_desktop_core::sha256_hex_reader; +use std::io::Cursor; + +#[test] +fn shared_sha256_reader_matches_the_fips_180_4_abc_vector() { + assert_eq!( + sha256_hex_reader(Cursor::new(b"abc")).as_deref(), + Ok("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + ); +} From d1ba40683772019577fec4d8c767ff8b23294e38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:14:17 +0900 Subject: [PATCH 285/448] fix(core): expose reusable SHA-256 reader boundary --- apps/desktop/core/src/content_sha256.rs | 79 +++++++++++++++++++++++++ apps/desktop/core/src/root.rs | 1 + 2 files changed, 80 insertions(+) diff --git a/apps/desktop/core/src/content_sha256.rs b/apps/desktop/core/src/content_sha256.rs index 3c217d58c..dbb109a49 100644 --- a/apps/desktop/core/src/content_sha256.rs +++ b/apps/desktop/core/src/content_sha256.rs @@ -3,6 +3,8 @@ //! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer //! tests below are correctness checks, not CAVP validation or a FIPS 140 claim. +use std::io::{self, ErrorKind, Read}; + const BLOCK_BYTES: usize = 64; const DIGEST_BYTES: usize = 32; const INITIAL_STATE: [u32; 8] = [ @@ -170,9 +172,35 @@ impl StreamingSha256 { } } +/// Hash a caller-owned byte stream as canonical lowercase SHA-256. +/// +/// Security Notes: this helper never opens a path, logs bytes, or grants filesystem +/// authority. The caller must supply an already-authorized reader and decide how +/// the resulting digest is bound to a concrete artifact. `Interrupted` reads are +/// retried; other reader failures are returned unchanged. This is content identity, +/// not an authenticity primitive or a FIPS module-validation claim. +pub fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut digest = StreamingSha256::default(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => digest + .update(&chunk[..read_bytes]) + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large"))?, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + digest + .finalize_hex() + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large")) +} + #[cfg(test)] mod tests { use super::*; + use std::io::{Cursor, Error}; fn digest_in_chunks(bytes: &[u8], chunk_size: usize) -> String { let mut digest = StreamingSha256::default(); @@ -184,6 +212,36 @@ mod tests { .expect("test vector bit length must fit SHA-256") } + struct InterruptedShortReader { + bytes: Vec, + cursor: usize, + interrupted: bool, + } + + impl Read for InterruptedShortReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(Error::from(ErrorKind::Interrupted)); + } + if self.cursor == self.bytes.len() { + return Ok(0); + } + let copied = 7.min(output.len()).min(self.bytes.len() - self.cursor); + output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); + self.cursor += copied; + Ok(copied) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _output: &mut [u8]) -> io::Result { + Err(Error::new(ErrorKind::Other, "fixture read failure")) + } + } + #[test] fn matches_sha256_known_answer_vectors() { for (message, expected) in [ @@ -204,6 +262,27 @@ mod tests { } } + #[test] + fn shared_reader_retries_interrupted_short_reads() { + let bytes = (0..131_111) + .map(|index| (index % 251) as u8) + .collect::>(); + let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference hash should succeed"); + let actual = sha256_hex_reader(InterruptedShortReader { + bytes, + cursor: 0, + interrupted: false, + }) + .expect("interrupted short reads should be retried"); + assert_eq!(actual, expected); + } + + #[test] + fn shared_reader_propagates_non_interrupted_failure() { + let error = sha256_hex_reader(FailingReader).expect_err("reader failure must propagate"); + assert_eq!(error.kind(), ErrorKind::Other); + } + #[test] fn matches_the_million_a_vector() { assert_eq!( diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 24f412ab6..d76cec65e 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -15,5 +15,6 @@ pub use audio_resource::{ copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, validate_local_audio_file_size, LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, }; +pub use content_sha256::sha256_hex_reader; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; From 8a4f50c140448f53dbb6dd96b990eb8ab2caa5db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:15:22 +0900 Subject: [PATCH 286/448] docs(core): record reusable SHA-256 consolidation port --- .../local-audio-source-materialization.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index d6685f228..8b9d8818d 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -10,6 +10,8 @@ A later review found a narrower diagnostics defect in the bounded-copy port. `st The next persistence handoff exposed a separate evidence gap: the bounded copy returned only a byte count. Project Persistence therefore had no native content identity that proved which admitted bytes were staged. A renderer-generated digest would invert the trust boundary, and hashing a mutable external path later would no longer identify the app-owned artifact that analysis actually consumes. +A post-fix review found one more ownership defect. The new SHA-256 state was private to Resource Admission, while #1160 already had a second private playable-stem SHA-256 implementation. Merely documenting future consolidation was not enough: without a reusable core reader port, the dependent Active Player stack could not actually delete its copy. The Shared Kernel therefore needs a reader-based digest API that preserves the same bounded authority model without opening paths itself. + ## Constraints - Local analysis remains local-first and introduces no network or generic filesystem capability. @@ -20,6 +22,7 @@ The next persistence handoff exposed a separate evidence gap: the bounded copy r - Transient `Interrupted` reads must be retried rather than misdiagnosed as unreadable media. - Content identity must be computed from the exact byte slices whose staging writes succeeded; the one-byte overflow probe is not part of the digest. - SHA-256 is used only as content-identity evidence. This implementation does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against a malicious actor who can replace both an artifact and its stored digest. +- A reusable SHA-256 port may hash only a caller-owned `Read`; it must not open arbitrary paths, log bytes, or introduce new filesystem authority. - The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. - The change must not claim that Tauri already persists the new receipt, project reopen is complete, YouTube source persistence is complete, power-loss recovery is complete, or commercial decoder licensing is solved. @@ -31,6 +34,7 @@ The next persistence handoff exposed a separate evidence gap: the bounded copy r 4. Keep `std::io::copy` and surface one generic copy error. Rejected because it cannot distinguish an untrusted source read failure from failure to write BandScope-owned project storage. A bounded explicit read/write loop preserves the same byte ceiling while keeping those trust-boundary failures separate. 5. Compute the persistence digest in the renderer or later from the original absolute path. Rejected because neither source is authoritative for the bytes successfully staged into BandScope-owned storage. 6. Add a second SHA-256 implementation or a new hashing path in Project Persistence. Rejected. The GUI-independent desktop core is the minimal Shared Kernel for this byte-identity primitive. Active Player's existing local playable-stem SHA-256 implementation must migrate to this canonical primitive when its dependent stack is restacked rather than remain a divergent copy. +7. Keep the core SHA-256 state private and ask each consumer to wrap or copy it. Rejected because that makes the documented consolidation impossible. A public reader-only `sha256_hex_reader` is the narrow reusable boundary: consumers retain authority over which already-authorized descriptor they supply, while the core owns one digest implementation. ## Implementation and exact evidence @@ -43,7 +47,9 @@ The next persistence handoff exposed a separate evidence gap: the bounded copy r - Causal fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` replaces the ambiguous `std::io::copy` mapping with an explicit bounded read/write loop. It writes at most 100 MiB, performs a one-byte read-only probe after reaching the ceiling, preserves the media-read error for reader failures, and maps writer failures to the existing app-owned workspace error. It also adds the inverse reader-failure regression so the two diagnoses cannot collapse again. - Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` requires the Resource Admission port to return the exact staged byte count plus the SHA-256 known for bytes `01 02 03 04`. The predecessor has no such receipt API, so this is a genuine missing-contract RED rather than a mock success. - Causal fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` adds `LocalAudioCopyReceipt` and `copy_bounded_local_audio_with_receipt`. SHA-256 is updated only after the corresponding `write_all` succeeds, so a failed writer never yields identity evidence for an incomplete stage. The compatibility byte-count adapter remains for the current Tauri caller, and `Interrupted` source reads are retried without changing the resulting identity. -- The shared SHA-256 state is in GUI-independent core and is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. These are correctness regressions only; they are not CAVP or module-validation evidence. +- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` requires a public core reader boundary to reproduce the FIPS 180-4 `abc` SHA-256 vector. The predecessor cannot satisfy the import because its digest state is private to Resource Admission. +- Causal fix `d1ba40683772019577fec4d8c767ff8b23294e38` exports `sha256_hex_reader` from desktop core. It consumes only a caller-owned `Read`, retries `Interrupted`, propagates other I/O failures, does not open a path, and uses the same SHA-256 state as the local-audio receipt. This makes #1160 consolidation executable instead of aspirational. +- The shared SHA-256 state is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. The reader port also has interrupted-short-read and non-interrupted-failure regressions. These are correctness regressions only; they are not CAVP or module-validation evidence. Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. @@ -53,6 +59,8 @@ Hosted evidence must be reacquired on the final descendant rather than transferr The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. The content digest is evidence about bytes that successfully crossed that boundary into the staging writer; it is not authorization to reopen an arbitrary host path. +The shared reader port accepts no path and creates no descriptor. Callers such as Resource Admission or future Active Player stem admission must supply a descriptor they already own under their bounded-context authority. That keeps hashing reusable without turning the Shared Kernel into a filesystem service. + ### Validation and safe failure The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written or hashed as admitted content. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. `Interrupted` reads are retried. Neither failure path exposes the source path, destination path, raw OS error, media contents, or a misleading partial digest. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. @@ -72,15 +80,16 @@ No new logging, telemetry, network transfer, or path exposure is introduced. SHA - transient interrupted reads are retried and preserve the expected digest; - destination writer failure reports the bounded app-owned workspace diagnosis and cannot return a partial receipt; - SHA-256 matches authoritative known-answer vectors across short, multi-block, chunked, and one-million-byte inputs; +- the public reader boundary reproduces the same digest, retries interrupted reads, and propagates non-interrupted reader failure; - failed copy or flush does not publish the final project-owned source artifact; -- Tauri must compile against the exported Resource Admission ports; +- Tauri must compile against the exported Resource Admission and shared SHA-256 ports; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. ## Remaining risks and follow-up The core can now emit native streaming content identity for exactly the bytes successfully staged, but the current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter and therefore does not yet carry the digest into its bootstrap/persistence handoff. The next owner slice is to consume the receipt at that native caller, bind it to the synchronized and published `source.` artifact, and expose only the path-free identity fields needed by #970/#962. Reopen must then resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. -The private playable-stem SHA-256 implementation already present in #1160 is now a consolidation finding: once this Resource Admission foundation is available in that stack, #1160 must consume the shared core primitive and delete its local copy rather than maintain two security-sensitive implementations. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. +The private playable-stem SHA-256 implementation already present in #1160 is now a concrete consolidation finding with a consumable replacement port: when this Resource Admission foundation is available in that stack, #1160 must replace its local implementation with `bandscope_desktop_core::sha256_hex_reader` while retaining stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. ## References From fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:05:00 +0900 Subject: [PATCH 287/448] test(audio): require publication-bound source identity --- .../tests/local_audio_content_identity.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/local_audio_content_identity.rs b/apps/desktop/core/tests/local_audio_content_identity.rs index 5038c129d..b43eb1bc5 100644 --- a/apps/desktop/core/tests/local_audio_content_identity.rs +++ b/apps/desktop/core/tests/local_audio_content_identity.rs @@ -1,4 +1,6 @@ -use bandscope_desktop_core::copy_bounded_local_audio_with_receipt; +use bandscope_desktop_core::{ + copy_bounded_local_audio_with_receipt, verify_local_audio_publication_receipt, +}; use std::io::Cursor; #[test] @@ -16,3 +18,26 @@ fn local_audio_copy_receipt_hashes_exact_admitted_bytes() { "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" ); } + +#[test] +fn publication_receipt_requires_the_published_bytes_to_match_the_stage() { + let input = vec![1_u8, 2, 3, 4]; + let mut staged = Vec::new(); + let staged_receipt = copy_bounded_local_audio_with_receipt(Cursor::new(&input), &mut staged) + .expect("staging should produce native identity evidence"); + + let published_receipt = verify_local_audio_publication_receipt( + Cursor::new(&staged), + &staged_receipt, + ) + .expect("unchanged published bytes should retain the staging identity"); + + assert_eq!(published_receipt, staged_receipt); + + let mismatch = verify_local_audio_publication_receipt( + Cursor::new(vec![1_u8, 2, 3, 5]), + &staged_receipt, + ) + .expect_err("same-size mutation after staging must fail publication binding"); + assert_eq!(mismatch, "Could not prepare the local project workspace."); +} From a1c85cbfbdc7051169f097e8ad235e3bbac439d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:05:29 +0900 Subject: [PATCH 288/448] fix(audio): verify published source identity --- apps/desktop/core/src/audio_resource.rs | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index 0c8a890e1..2cbbc6219 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -107,6 +107,28 @@ pub fn copy_bounded_local_audio_with_receipt( copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES) } +/// Re-read a published app-owned source and prove that it matches its staging receipt. +/// +/// Security Notes: the caller must pass an already-open descriptor for the +/// synchronized, published `source.` object. This helper opens no +/// path and grants no filesystem authority. It re-applies the 100 MiB bound and +/// SHA-256 over the published bytes, then requires both size and digest to equal +/// the staging receipt. Any read, growth, truncation, or content mismatch is +/// reported as a bounded project-workspace failure because the selected source +/// already passed admission before publication. +pub fn verify_local_audio_publication_receipt( + reader: R, + expected: &LocalAudioCopyReceipt, +) -> Result { + let mut sink = std::io::sink(); + let actual = copy_bounded_local_audio_with_receipt(reader, &mut sink) + .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; + if actual != *expected { + return Err(LOCAL_AUDIO_WRITE_ERROR.to_string()); + } + Ok(actual) +} + /// Copy one admitted local-audio stream into a staging writer and return its byte count. /// /// This compatibility adapter preserves the existing desktop call boundary while @@ -225,4 +247,19 @@ mod tests { ); assert_eq!(staged, vec![1_u8, 2, 3, 4]); } + + #[test] + fn publication_verification_maps_read_failure_to_workspace_failure() { + let expected = LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + .to_string(), + }; + + let error = verify_local_audio_publication_receipt(FailingReader, &expected) + .expect_err("published artifact read failure must be a workspace failure"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + } } From 20e7faaddd619c6cbd053876ca6de27b9933a4a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:05:40 +0900 Subject: [PATCH 289/448] fix(audio): expose publication receipt verifier --- apps/desktop/core/src/root.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index d76cec65e..2d3eefc85 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -13,7 +13,8 @@ mod score_pdf; pub use audio_resource::{ copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, - validate_local_audio_file_size, LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, + validate_local_audio_file_size, verify_local_audio_publication_receipt, + LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, }; pub use content_sha256::sha256_hex_reader; pub use runtime_core::*; From 505d214be16522d9e3883ea1b4c804732080e8ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:06:30 +0900 Subject: [PATCH 290/448] docs(audio): trace publication receipt verification --- .../local-audio-source-materialization.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index 8b9d8818d..b8404c161 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -12,6 +12,8 @@ The next persistence handoff exposed a separate evidence gap: the bounded copy r A post-fix review found one more ownership defect. The new SHA-256 state was private to Resource Admission, while #1160 already had a second private playable-stem SHA-256 implementation. Merely documenting future consolidation was not enough: without a reusable core reader port, the dependent Active Player stack could not actually delete its copy. The Shared Kernel therefore needs a reader-based digest API that preserves the same bounded authority model without opening paths itself. +The staged receipt still did not prove that the object eventually published as `source.` retained those same bytes. A same-size mutation after staging could preserve `file_size_bytes` while changing content. Treating a pre-publication digest as durable project truth would therefore leave a gap exactly where Project Persistence needs native evidence. Resource Admission needs a bounded re-read verifier that compares both size and SHA-256 of an already-open published artifact against the staging receipt before that receipt can be handed to persistence. + ## Constraints - Local analysis remains local-first and introduces no network or generic filesystem capability. @@ -23,6 +25,7 @@ A post-fix review found one more ownership defect. The new SHA-256 state was pri - Content identity must be computed from the exact byte slices whose staging writes succeeded; the one-byte overflow probe is not part of the digest. - SHA-256 is used only as content-identity evidence. This implementation does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against a malicious actor who can replace both an artifact and its stored digest. - A reusable SHA-256 port may hash only a caller-owned `Read`; it must not open arbitrary paths, log bytes, or introduce new filesystem authority. +- Publication verification likewise accepts only an already-authorized `Read`; path resolution and no-link containment stay with the native caller that owns app storage authority. - The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. - The change must not claim that Tauri already persists the new receipt, project reopen is complete, YouTube source persistence is complete, power-loss recovery is complete, or commercial decoder licensing is solved. @@ -35,6 +38,7 @@ A post-fix review found one more ownership defect. The new SHA-256 state was pri 5. Compute the persistence digest in the renderer or later from the original absolute path. Rejected because neither source is authoritative for the bytes successfully staged into BandScope-owned storage. 6. Add a second SHA-256 implementation or a new hashing path in Project Persistence. Rejected. The GUI-independent desktop core is the minimal Shared Kernel for this byte-identity primitive. Active Player's existing local playable-stem SHA-256 implementation must migrate to this canonical primitive when its dependent stack is restacked rather than remain a divergent copy. 7. Keep the core SHA-256 state private and ask each consumer to wrap or copy it. Rejected because that makes the documented consolidation impossible. A public reader-only `sha256_hex_reader` is the narrow reusable boundary: consumers retain authority over which already-authorized descriptor they supply, while the core owns one digest implementation. +8. Treat the staging receipt as publication identity immediately after rename. Rejected because a same-size content change would not be detected by byte-count checks alone. Selected instead: re-read an already-open published artifact through the same 100 MiB/SHA-256 path and require exact receipt equality before persistence may consume the identity. ## Implementation and exact evidence @@ -49,6 +53,9 @@ A post-fix review found one more ownership defect. The new SHA-256 state was pri - Causal fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` adds `LocalAudioCopyReceipt` and `copy_bounded_local_audio_with_receipt`. SHA-256 is updated only after the corresponding `write_all` succeeds, so a failed writer never yields identity evidence for an incomplete stage. The compatibility byte-count adapter remains for the current Tauri caller, and `Interrupted` source reads are retried without changing the resulting identity. - Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` requires a public core reader boundary to reproduce the FIPS 180-4 `abc` SHA-256 vector. The predecessor cannot satisfy the import because its digest state is private to Resource Admission. - Causal fix `d1ba40683772019577fec4d8c767ff8b23294e38` exports `sha256_hex_reader` from desktop core. It consumes only a caller-owned `Read`, retries `Interrupted`, propagates other I/O failures, does not open a path, and uses the same SHA-256 state as the local-audio receipt. This makes #1160 consolidation executable instead of aspirational. +- Publication-binding RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` requires an unchanged published byte stream to reproduce the staging receipt and a same-size mutation to fail with the bounded project-workspace diagnosis. The predecessor has no publication verifier. +- Causal fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` adds `verify_local_audio_publication_receipt`. It accepts only an already-open reader, reuses the same bounded staging/hash path with an in-memory sink, and requires exact byte-count plus SHA-256 equality. Read failure, growth, truncation, or content mismatch is normalized to the project-workspace error because original source admission has already completed by this boundary. +- Export repair `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exposes the publication verifier from `bandscope_desktop_core`, making the next Tauri caller integration executable without source copying. - The shared SHA-256 state is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. The reader port also has interrupted-short-read and non-interrupted-failure regressions. These are correctness regressions only; they are not CAVP or module-validation evidence. Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. @@ -59,13 +66,13 @@ Hosted evidence must be reacquired on the final descendant rather than transferr The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. The content digest is evidence about bytes that successfully crossed that boundary into the staging writer; it is not authorization to reopen an arbitrary host path. -The shared reader port accepts no path and creates no descriptor. Callers such as Resource Admission or future Active Player stem admission must supply a descriptor they already own under their bounded-context authority. That keeps hashing reusable without turning the Shared Kernel into a filesystem service. +The shared reader and publication-verification ports accept no path and create no descriptor. Callers such as Resource Admission or future Active Player stem admission must supply a descriptor they already own under their bounded-context authority. That keeps hashing reusable without turning the Shared Kernel into a filesystem service. The publication verifier additionally refuses to promote a staging receipt when the published bytes do not reproduce both its exact size and digest. ### Validation and safe failure The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written or hashed as admitted content. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. `Interrupted` reads are retried. Neither failure path exposes the source path, destination path, raw OS error, media contents, or a misleading partial digest. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. -The receipt is currently bound to the byte stream whose writes succeeded, not yet to a post-rename descriptor identity. Tauri must switch from the compatibility byte-count adapter to the receipt API and then verify that the synchronized/published artifact is the same app-owned object before the digest becomes durable `sourceReference` truth. Same-size external mutation of a staging or final artifact remains a threat until that publication binding and reopen verification are complete. +`verify_local_audio_publication_receipt` re-reads an already-open published object under the same non-zero/100 MiB bound and compares exact native identity. Any verification read failure, overgrowth, truncation, or digest mismatch fails closed as a project-workspace error and exposes no path or OS detail. The current Tauri caller has not yet been switched to this verifier, so descriptor acquisition/no-link containment and final handoff remain incomplete production integration rather than claimed behavior. ### Logging and privacy @@ -81,13 +88,16 @@ No new logging, telemetry, network transfer, or path exposure is introduced. SHA - destination writer failure reports the bounded app-owned workspace diagnosis and cannot return a partial receipt; - SHA-256 matches authoritative known-answer vectors across short, multi-block, chunked, and one-million-byte inputs; - the public reader boundary reproduces the same digest, retries interrupted reads, and propagates non-interrupted reader failure; +- an unchanged published reader reproduces the staging receipt; +- same-size published-content mutation is rejected even when byte count is unchanged; +- publication-verification read failure is normalized to the bounded project-workspace diagnosis; - failed copy or flush does not publish the final project-owned source artifact; -- Tauri must compile against the exported Resource Admission and shared SHA-256 ports; +- Tauri must compile against the exported Resource Admission, publication-verification, and shared SHA-256 ports; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. ## Remaining risks and follow-up -The core can now emit native streaming content identity for exactly the bytes successfully staged, but the current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter and therefore does not yet carry the digest into its bootstrap/persistence handoff. The next owner slice is to consume the receipt at that native caller, bind it to the synchronized and published `source.` artifact, and expose only the path-free identity fields needed by #970/#962. Reopen must then resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. +The core can now emit native streaming identity for exactly the bytes successfully staged and can verify that an already-open published byte stream reproduces that receipt. The current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter, so this run does not claim production publication binding complete. The next owner slice is to switch that native caller to `copy_bounded_local_audio_with_receipt`, synchronize and publish `source.`, open the published object under app-owned/no-link authority, call `verify_local_audio_publication_receipt`, and only then expose the path-free identity fields needed by #970/#962. Reopen must resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. The private playable-stem SHA-256 implementation already present in #1160 is now a concrete consolidation finding with a consumable replacement port: when this Resource Admission foundation is available in that stack, #1160 must replace its local implementation with `bandscope_desktop_core::sha256_hex_reader` while retaining stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. From 6a0692ee288d3b126bd0598e07e03c88a702d567 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:00:57 +0900 Subject: [PATCH 291/448] test(audio): bound publication verification to receipt size --- apps/desktop/core/src/audio_resource.rs | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index 2cbbc6219..cbdab46cc 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -178,6 +178,19 @@ mod tests { } } + struct CountingReader { + bytes: Cursor>, + bytes_read: usize, + } + + impl Read for CountingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let read = self.bytes.read(buffer)?; + self.bytes_read += read; + Ok(read) + } + } + #[test] fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() { let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]); @@ -262,4 +275,24 @@ mod tests { assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); } + + #[test] + fn publication_verification_stops_after_expected_size_plus_one_probe_byte() { + let expected = LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + .to_string(), + }; + let mut published = CountingReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4, 5, 6, 7, 8]), + bytes_read: 0, + }; + + let error = verify_local_audio_publication_receipt(&mut published, &expected) + .expect_err("a grown published artifact must fail without scanning unrelated tail bytes"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + assert_eq!(published.bytes_read, 5); + } } From c65a9fd312f4d67e6d1cad83b80b1213e692c8dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:01:35 +0900 Subject: [PATCH 292/448] fix(audio): stop publication verification at expected bytes --- apps/desktop/core/src/audio_resource.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index cbdab46cc..d9f05e802 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -111,17 +111,24 @@ pub fn copy_bounded_local_audio_with_receipt( /// /// Security Notes: the caller must pass an already-open descriptor for the /// synchronized, published `source.` object. This helper opens no -/// path and grants no filesystem authority. It re-applies the 100 MiB bound and -/// SHA-256 over the published bytes, then requires both size and digest to equal -/// the staging receipt. Any read, growth, truncation, or content mismatch is -/// reported as a bounded project-workspace failure because the selected source -/// already passed admission before publication. +/// path and grants no filesystem authority. The staging receipt is native +/// evidence from the prior bounded copy, so its byte length becomes the tighter +/// publication-read ceiling: the verifier hashes at most that many bytes and +/// reads one additional probe byte to reject growth. It then requires both size +/// and digest to equal the staging receipt. Any invalid expected length, read, +/// growth, truncation, or content mismatch is reported as a bounded +/// project-workspace failure because the selected source already passed +/// admission before publication. pub fn verify_local_audio_publication_receipt( reader: R, expected: &LocalAudioCopyReceipt, ) -> Result { + if expected.file_size_bytes == 0 || expected.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES { + return Err(LOCAL_AUDIO_WRITE_ERROR.to_string()); + } + let mut sink = std::io::sink(); - let actual = copy_bounded_local_audio_with_receipt(reader, &mut sink) + let actual = copy_bounded_local_audio_with_limit(reader, &mut sink, expected.file_size_bytes) .map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?; if actual != *expected { return Err(LOCAL_AUDIO_WRITE_ERROR.to_string()); From 92f436a2d6feebffb01761139fab34e86975fcec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:02:29 +0900 Subject: [PATCH 293/448] docs(audio): bound publication verification read evidence --- .../local-audio-source-materialization.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index b8404c161..6169d5e67 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -14,6 +14,8 @@ A post-fix review found one more ownership defect. The new SHA-256 state was pri The staged receipt still did not prove that the object eventually published as `source.` retained those same bytes. A same-size mutation after staging could preserve `file_size_bytes` while changing content. Treating a pre-publication digest as durable project truth would therefore leave a gap exactly where Project Persistence needs native evidence. Resource Admission needs a bounded re-read verifier that compares both size and SHA-256 of an already-open published artifact against the staging receipt before that receipt can be handed to persistence. +A further review found that the first publication verifier bounded its re-read only by the global 100 MiB admission ceiling. If a 4 MiB staged artifact were replaced by a much larger app-owned object before verification, the verifier could hash the entire replacement up to 100 MiB before discovering the receipt mismatch. The staging receipt already contains a native exact byte count, so publication verification should use that value as the tighter read ceiling and inspect only one additional probe byte for growth. This does not change what can be accepted; it reduces the work performed on an already-invalid published object and makes the publication check proportional to the admitted artifact rather than the product-wide maximum. + ## Constraints - Local analysis remains local-first and introduces no network or generic filesystem capability. @@ -26,6 +28,7 @@ The staged receipt still did not prove that the object eventually published as ` - SHA-256 is used only as content-identity evidence. This implementation does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against a malicious actor who can replace both an artifact and its stored digest. - A reusable SHA-256 port may hash only a caller-owned `Read`; it must not open arbitrary paths, log bytes, or introduce new filesystem authority. - Publication verification likewise accepts only an already-authorized `Read`; path resolution and no-link containment stay with the native caller that owns app storage authority. +- Publication verification must reject an invalid native receipt length before reading and must read at most the expected admitted byte count plus one probe byte when checking for growth. - The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. - The change must not claim that Tauri already persists the new receipt, project reopen is complete, YouTube source persistence is complete, power-loss recovery is complete, or commercial decoder licensing is solved. @@ -38,7 +41,8 @@ The staged receipt still did not prove that the object eventually published as ` 5. Compute the persistence digest in the renderer or later from the original absolute path. Rejected because neither source is authoritative for the bytes successfully staged into BandScope-owned storage. 6. Add a second SHA-256 implementation or a new hashing path in Project Persistence. Rejected. The GUI-independent desktop core is the minimal Shared Kernel for this byte-identity primitive. Active Player's existing local playable-stem SHA-256 implementation must migrate to this canonical primitive when its dependent stack is restacked rather than remain a divergent copy. 7. Keep the core SHA-256 state private and ask each consumer to wrap or copy it. Rejected because that makes the documented consolidation impossible. A public reader-only `sha256_hex_reader` is the narrow reusable boundary: consumers retain authority over which already-authorized descriptor they supply, while the core owns one digest implementation. -8. Treat the staging receipt as publication identity immediately after rename. Rejected because a same-size content change would not be detected by byte-count checks alone. Selected instead: re-read an already-open published artifact through the same 100 MiB/SHA-256 path and require exact receipt equality before persistence may consume the identity. +8. Treat the staging receipt as publication identity immediately after rename. Rejected because a same-size content change would not be detected by byte-count checks alone. Selected instead: re-read an already-open published artifact through the same bounded SHA-256 path and require exact receipt equality before persistence may consume the identity. +9. Re-read every published artifact up to the global 100 MiB ceiling before comparing the receipt. Rejected because the native receipt already supplies a tighter exact byte count. Selected instead: use `expected.file_size_bytes` as the publication read ceiling and read one additional probe byte. Growth then fails immediately after the expected boundary, while truncation and same-size mutation still fail through exact size/digest comparison. ## Implementation and exact evidence @@ -56,6 +60,8 @@ The staged receipt still did not prove that the object eventually published as ` - Publication-binding RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` requires an unchanged published byte stream to reproduce the staging receipt and a same-size mutation to fail with the bounded project-workspace diagnosis. The predecessor has no publication verifier. - Causal fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` adds `verify_local_audio_publication_receipt`. It accepts only an already-open reader, reuses the same bounded staging/hash path with an in-memory sink, and requires exact byte-count plus SHA-256 equality. Read failure, growth, truncation, or content mismatch is normalized to the project-workspace error because original source admission has already completed by this boundary. - Export repair `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exposes the publication verifier from `bandscope_desktop_core`, making the next Tauri caller integration executable without source copying. +- Verification-bound RED `6a0692ee288d3b126bd0598e07e03c88a702d567` adds a counting-reader regression for an artifact expected to be 4 bytes but grown to 8 bytes. The predecessor verifier scans all 8 bytes because it uses the global 100 MiB ceiling; the contract requires it to stop after the four expected bytes plus one growth probe. +- Causal fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` validates the native expected length, uses `expected.file_size_bytes` as the publication-read ceiling, and maps the one-byte growth probe back to the bounded workspace diagnosis. A grown published object is therefore rejected after `expected + 1` bytes rather than being hashed up to the product-wide ceiling. - The shared SHA-256 state is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. The reader port also has interrupted-short-read and non-interrupted-failure regressions. These are correctness regressions only; they are not CAVP or module-validation evidence. Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. @@ -72,7 +78,7 @@ The shared reader and publication-verification ports accept no path and create n The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written or hashed as admitted content. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. `Interrupted` reads are retried. Neither failure path exposes the source path, destination path, raw OS error, media contents, or a misleading partial digest. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. -`verify_local_audio_publication_receipt` re-reads an already-open published object under the same non-zero/100 MiB bound and compares exact native identity. Any verification read failure, overgrowth, truncation, or digest mismatch fails closed as a project-workspace error and exposes no path or OS detail. The current Tauri caller has not yet been switched to this verifier, so descriptor acquisition/no-link containment and final handoff remain incomplete production integration rather than claimed behavior. +`verify_local_audio_publication_receipt` rejects an expected length of zero or greater than 100 MiB before consuming the published reader. For a valid staging receipt it reads and hashes at most the exact admitted byte count and then one probe byte. Any verification read failure, one-byte-or-greater growth, truncation, or digest mismatch fails closed as a project-workspace error and exposes no path or OS detail. The current Tauri caller has not yet been switched to this verifier, so descriptor acquisition/no-link containment and final handoff remain incomplete production integration rather than claimed behavior. ### Logging and privacy @@ -91,13 +97,15 @@ No new logging, telemetry, network transfer, or path exposure is introduced. SHA - an unchanged published reader reproduces the staging receipt; - same-size published-content mutation is rejected even when byte count is unchanged; - publication-verification read failure is normalized to the bounded project-workspace diagnosis; +- a grown published artifact is rejected after the expected byte count plus one probe byte rather than scanning unrelated tail bytes up to 100 MiB; +- invalid expected publication lengths fail before consuming published bytes; - failed copy or flush does not publish the final project-owned source artifact; - Tauri must compile against the exported Resource Admission, publication-verification, and shared SHA-256 ports; - hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. ## Remaining risks and follow-up -The core can now emit native streaming identity for exactly the bytes successfully staged and can verify that an already-open published byte stream reproduces that receipt. The current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter, so this run does not claim production publication binding complete. The next owner slice is to switch that native caller to `copy_bounded_local_audio_with_receipt`, synchronize and publish `source.`, open the published object under app-owned/no-link authority, call `verify_local_audio_publication_receipt`, and only then expose the path-free identity fields needed by #970/#962. Reopen must resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. +The core can now emit native streaming identity for exactly the bytes successfully staged and can verify that an already-open published byte stream reproduces that receipt without scanning beyond the expected artifact plus one growth probe. The current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter, so this run does not claim production publication binding complete. The next owner slice is to switch that native caller to `copy_bounded_local_audio_with_receipt`, synchronize and publish `source.`, open the published object under app-owned/no-link authority, call `verify_local_audio_publication_receipt`, and only then expose the path-free identity fields needed by #970/#962. Reopen must resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. The private playable-stem SHA-256 implementation already present in #1160 is now a concrete consolidation finding with a consumable replacement port: when this Resource Admission foundation is available in that stack, #1160 must replace its local implementation with `bandscope_desktop_core::sha256_hex_reader` while retaining stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. From dedaab78f8837dc6c1f4074c7a792ae999f49550 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:03:14 +0900 Subject: [PATCH 294/448] test(audio): reject invalid publication receipt lengths --- apps/desktop/core/src/audio_resource.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs index d9f05e802..a8383067b 100644 --- a/apps/desktop/core/src/audio_resource.rs +++ b/apps/desktop/core/src/audio_resource.rs @@ -302,4 +302,24 @@ mod tests { assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); assert_eq!(published.bytes_read, 5); } + + #[test] + fn publication_verification_rejects_impossible_expected_lengths_without_reading() { + for file_size_bytes in [0, MAX_LOCAL_AUDIO_FILE_BYTES + 1] { + let expected = LocalAudioCopyReceipt { + file_size_bytes, + content_sha256: "00".repeat(32), + }; + let mut published = CountingReader { + bytes: Cursor::new(vec![1_u8, 2, 3, 4]), + bytes_read: 0, + }; + + let error = verify_local_audio_publication_receipt(&mut published, &expected) + .expect_err("an impossible native receipt length must fail before reading"); + + assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR); + assert_eq!(published.bytes_read, 0); + } + } } From ed9fe7eba6261753dc0f68e820e2b642703fe2cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:03:21 +0900 Subject: [PATCH 295/448] test(audio): require publication-bound materializer receipt --- .../tests/local_audio_publication_contract.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/local_audio_publication_contract.rs diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs new file mode 100644 index 000000000..2b8e87f14 --- /dev/null +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -0,0 +1,25 @@ +#[test] +fn local_audio_materializer_consumes_publication_bound_receipt() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("copy_bounded_local_audio_with_receipt"), + "production materialization must retain native size+SHA-256 staging evidence" + ); + assert!( + materializer.contains("verify_local_audio_publication_receipt"), + "production materialization must re-read the published app-owned source and bind it to the staging receipt" + ); + assert!( + !materializer.contains("copy_bounded_local_audio(source"), + "the compatibility byte-count-only adapter must not remain on the production publication path" + ); +} From bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:05:29 +0900 Subject: [PATCH 296/448] fix(audio): bind published source to native receipt --- apps/desktop/src-tauri/src/main.rs | 62 ++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 994f55418..729b6c581 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -146,10 +146,14 @@ fn app_owned_root( /// Security Notes: the external path is used only to canonicalize and open the /// user-authorized source. Size is checked from that opened descriptor, bytes /// are copied through the bounded Resource Admission helper into a private -/// same-project staging file, and only a successful flushed stage is renamed to -/// `source.`. Bootstrap state therefore points at app-owned bytes; -/// a later mutation, move, permission change, or replacement of the user's -/// original path cannot change the bytes submitted to analysis. +/// same-project staging file, and a synchronized stage is renamed to +/// `source.`. The published object is then required to remain a +/// regular non-symlink filesystem entry and its opened bytes must reproduce the +/// staging size+SHA-256 receipt before bootstrap authority is returned. This +/// keeps later analysis bound to the app-owned publication rather than the +/// mutable user-selected path. Atomic no-follow descriptor acquisition remains +/// a separate platform-hardening requirement; these portable checks do not +/// claim to provide O_NOFOLLOW-equivalent race semantics. fn materialize_local_audio_source( path: &Path, project_root: &Path, @@ -188,8 +192,8 @@ fn materialize_local_audio_source( .open(&stage) .map_err(|_| "Could not prepare the local project workspace.".to_string())?; - let file_size_bytes = match copy_bounded_local_audio(source, &mut staged) { - Ok(file_size_bytes) => file_size_bytes, + let receipt = match copy_bounded_local_audio_with_receipt(source, &mut staged) { + Ok(receipt) => receipt, Err(error) => { drop(staged); let _ = std::fs::remove_file(&stage); @@ -212,11 +216,55 @@ fn materialize_local_audio_source( return Err("Could not prepare the local project workspace.".to_string()); } + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published = match std::fs::File::open(&destination) { + Ok(file) => file, + Err(_) => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + let published_descriptor_metadata = match published.metadata() { + Ok(metadata) if metadata.is_file() && metadata.len() == receipt.file_size_bytes => metadata, + _ => { + drop(published); + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_descriptor_metadata.len() != published_path_metadata.len() + || verify_local_audio_publication_receipt(published, &receipt).is_err() + { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + Ok(LocalAudioSourcePayload { source_path: destination.to_string_lossy().into_owned(), file_name, extension, - file_size_bytes, + file_size_bytes: receipt.file_size_bytes, }) } From 539bd575d33bd494291899e21a6bcb688b3be202 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:18:25 +0900 Subject: [PATCH 297/448] docs(audio): align publication identity traceability --- .../local-audio-source-materialization.md | 155 +++++++++--------- 1 file changed, 74 insertions(+), 81 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index 6169d5e67..756c02149 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -2,112 +2,105 @@ ## Problem -The desktop intake boundary previously validated an OS-selected local audio file, then stored the canonical external filesystem path in `ProjectBootstrapSummaryPayload`. Analysis could therefore reopen bytes from a path the application did not own after the original metadata admission. A source could be moved, replaced, truncated, or grown between selection and analysis, and process restart could not reconstruct a trustworthy full-mix source from app-owned project state. +BandScope originally validated an OS-selected local audio file and then let later analysis reopen the canonical external filesystem path. That left analysis and restart dependent on mutable host authority: the selected file could be moved, replaced, truncated, or grown after admission. Project Persistence #962 also needs a durable source identity that does not serialize an arbitrary user filesystem path. -Issue #962 now makes durable local-source re-admission a Project Persistence prerequisite. That satisfies the existing application-security condition that copying selected media is justified when persistence requirements require an additional storage boundary. Resource Admission & Decode owns creation of that app-owned audio artifact; Project Persistence owns only the versioned reference and migration contract that consumes it. +Resource Admission & Decode therefore owns creation and verification of the app-owned `source.` artifact. Project Persistence owns the later versioned reference that consumes native evidence from that artifact; it does not copy or hash user media itself. -A later review found a narrower diagnostics defect in the bounded-copy port. `std::io::copy` reports both reader and writer failures through one `io::Error`, and the predecessor mapped every such error to `Could not read the selected audio file.` A full or failing app-owned destination could therefore be reported as corrupt/unreadable source media. The same implementation also wrote the one-byte overflow probe into the disposable stage before rejecting an over-limit source. Neither behavior widened published authority, but both weakened failure diagnosis and the stated encoded-byte staging boundary. +The implementation accumulated several narrower defects while that boundary was being hardened: -The next persistence handoff exposed a separate evidence gap: the bounded copy returned only a byte count. Project Persistence therefore had no native content identity that proved which admitted bytes were staged. A renderer-generated digest would invert the trust boundary, and hashing a mutable external path later would no longer identify the app-owned artifact that analysis actually consumes. +- `std::io::copy` collapsed source-read and app-owned destination-write failures into the same buyer diagnosis; +- the one-byte over-limit probe was initially written into the disposable stage; +- the bounded copy returned only a byte count, so persistence had no native identity for the exact bytes written; +- SHA-256 existed in more than one security-sensitive implementation and initially had no reusable reader-only core port; +- a staging receipt alone did not prove that the final published object still contained the same bytes; +- publication verification initially used the product-wide 100 MiB ceiling rather than the receipt's tighter expected length; +- after the core receipt and verifier existed, the production Tauri materializer still called the compatibility byte-count-only adapter and discarded SHA-256 evidence. -A post-fix review found one more ownership defect. The new SHA-256 state was private to Resource Admission, while #1160 already had a second private playable-stem SHA-256 implementation. Merely documenting future consolidation was not enough: without a reusable core reader port, the dependent Active Player stack could not actually delete its copy. The Shared Kernel therefore needs a reader-based digest API that preserves the same bounded authority model without opening paths itself. - -The staged receipt still did not prove that the object eventually published as `source.` retained those same bytes. A same-size mutation after staging could preserve `file_size_bytes` while changing content. Treating a pre-publication digest as durable project truth would therefore leave a gap exactly where Project Persistence needs native evidence. Resource Admission needs a bounded re-read verifier that compares both size and SHA-256 of an already-open published artifact against the staging receipt before that receipt can be handed to persistence. - -A further review found that the first publication verifier bounded its re-read only by the global 100 MiB admission ceiling. If a 4 MiB staged artifact were replaced by a much larger app-owned object before verification, the verifier could hash the entire replacement up to 100 MiB before discovering the receipt mismatch. The staging receipt already contains a native exact byte count, so publication verification should use that value as the tighter read ceiling and inspect only one additional probe byte for growth. This does not change what can be accepted; it reduces the work performed on an already-invalid published object and makes the publication check proportional to the admitted artifact rather than the product-wide maximum. +The last item is now repaired on the canonical #866 branch: the production local-file materializer consumes the native receipt and re-verifies the published app-owned object before returning bootstrap authority. Path-free digest handoff into #970, restart re-admission, platform-atomic no-follow acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. ## Constraints -- Local analysis remains local-first and introduces no network or generic filesystem capability. -- The renderer must not choose an arbitrary path for analysis or persistence. -- The encoded-byte ceiling remains 100 MiB. -- An initial metadata length is not sufficient evidence if the selected file changes while it is being admitted. -- Source-read failures and app-owned destination-write failures must remain distinguishable without exposing paths or OS error details. -- Transient `Interrupted` reads must be retried rather than misdiagnosed as unreadable media. -- Content identity must be computed from the exact byte slices whose staging writes succeeded; the one-byte overflow probe is not part of the digest. -- SHA-256 is used only as content-identity evidence. This implementation does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against a malicious actor who can replace both an artifact and its stored digest. -- A reusable SHA-256 port may hash only a caller-owned `Read`; it must not open arbitrary paths, log bytes, or introduce new filesystem authority. -- Publication verification likewise accepts only an already-authorized `Read`; path resolution and no-link containment stay with the native caller that owns app storage authority. -- Publication verification must reject an invalid native receipt length before reading and must read at most the expected admitted byte count plus one probe byte when checking for growth. -- The user-visible source label may preserve the selected filename, but analysis authority must move to app-owned storage. -- The change must not claim that Tauri already persists the new receipt, project reopen is complete, YouTube source persistence is complete, power-loss recovery is complete, or commercial decoder licensing is solved. - -## Alternatives - -1. Keep the canonical external path and revalidate immediately before every analysis. Rejected because process restart still depends on mutable external authority and durable project references remain non-portable. -2. Persist the absolute external path in the `.bscope` document. Rejected because #962 explicitly separates portable project identity from arbitrary host paths and because it widens disclosure and authority. -3. Copy the selected local file into the project root after native admission. Selected. It produces the stable `source.` artifact expected by the Project Persistence source-reference contract without allowing the renderer to mint filesystem authority. -4. Keep `std::io::copy` and surface one generic copy error. Rejected because it cannot distinguish an untrusted source read failure from failure to write BandScope-owned project storage. A bounded explicit read/write loop preserves the same byte ceiling while keeping those trust-boundary failures separate. -5. Compute the persistence digest in the renderer or later from the original absolute path. Rejected because neither source is authoritative for the bytes successfully staged into BandScope-owned storage. -6. Add a second SHA-256 implementation or a new hashing path in Project Persistence. Rejected. The GUI-independent desktop core is the minimal Shared Kernel for this byte-identity primitive. Active Player's existing local playable-stem SHA-256 implementation must migrate to this canonical primitive when its dependent stack is restacked rather than remain a divergent copy. -7. Keep the core SHA-256 state private and ask each consumer to wrap or copy it. Rejected because that makes the documented consolidation impossible. A public reader-only `sha256_hex_reader` is the narrow reusable boundary: consumers retain authority over which already-authorized descriptor they supply, while the core owns one digest implementation. -8. Treat the staging receipt as publication identity immediately after rename. Rejected because a same-size content change would not be detected by byte-count checks alone. Selected instead: re-read an already-open published artifact through the same bounded SHA-256 path and require exact receipt equality before persistence may consume the identity. -9. Re-read every published artifact up to the global 100 MiB ceiling before comparing the receipt. Rejected because the native receipt already supplies a tighter exact byte count. Selected instead: use `expected.file_size_bytes` as the publication read ceiling and read one additional probe byte. Growth then fails immediately after the expected boundary, while truncation and same-size mutation still fail through exact size/digest comparison. +- Local analysis remains local-first; this boundary adds no network authority. +- Renderer input never selects an arbitrary analysis or persistence path. +- The encoded-byte ceiling remains exactly 100 MiB. +- Metadata length before copying is not final evidence when the selected source can change during admission. +- Source-read failure and app-owned write/publication failure remain distinguishable without exposing paths or raw OS errors. +- `Interrupted` reads are retried. +- SHA-256 covers only byte slices whose staging writes succeeded. The one-byte growth probe is not admitted content and is not hashed into the receipt. +- SHA-256 is content-identity/correctness evidence only. This code does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against an actor who can replace both artifact and stored digest. +- Reusable SHA-256 and publication-verification APIs accept only caller-owned `Read` values. They do not open arbitrary paths or create filesystem authority. +- Publication verification rejects an invalid native receipt length before reading and consumes at most `expected.file_size_bytes + 1` bytes. +- The selected filename may remain a user-facing label, but local-analysis authority moves to app-owned storage. +- Portable `symlink_metadata` / open / re-check logic narrows linked-object substitution but does not claim atomic `O_NOFOLLOW` or Windows reparse-point-equivalent semantics. +- This slice does not claim that the digest is already persisted in `.bscope`, restart/reopen is complete, YouTube persistence is complete, parent-directory publication is crash-durable, or the commercial decoder-license gate is solved. + +## Decision record + +1. Keep the external canonical path and revalidate before every analysis — rejected. Restart and persistence would still depend on mutable host authority. +2. Persist the absolute external path — rejected. It widens disclosure and violates the path-free #962 direction. +3. Copy the selected file into a project-owned `source.` artifact — selected. This gives later analysis a stable app-owned authority. +4. Keep `std::io::copy` and one generic error — rejected. Explicit bounded read/write preserves the same ceiling while separating source and destination failures. +5. Hash later in the renderer or from the original path — rejected. Neither is authoritative for the bytes actually staged into BandScope storage. +6. Add another SHA-256 implementation in persistence or Active Player — rejected. `bandscope_desktop_core::sha256_hex_reader` is the minimal reader-only Shared Kernel. +7. Treat the staging receipt as publication truth without rereading — rejected. Same-size mutation would evade byte-count checks. +8. Re-read every published object up to 100 MiB — rejected. The native receipt supplies a tighter expected length, so verification reads only expected bytes plus one growth probe. +9. Leave the Tauri caller on `copy_bounded_local_audio -> u64` — rejected. Production publication must retain the native receipt, synchronize and publish the stage, reopen the app-owned object, and require exact size+SHA-256 equality before bootstrap authority is returned. ## Implementation and exact evidence -- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` as an ordinary second parent with no force push. The Resource Admission semantic delta remains a descendant of the current protected base. -- RED `804a2867e877947feaffb1da6c6072e6a49049fe` added bounded-copy regressions for exact-limit acceptance and one-byte-over growth rejection. -- Core fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` added `copy_bounded_local_audio` and the 100 MiB admission boundary. -- Native integration `a2b1bd9e33a69be75f813f005abd37345200ce55` creates the project root before source admission, opens the OS-selected source natively, stages bytes under that project root, flushes the staged file, and publishes `source.` only after bounded copy succeeds. `ProjectBootstrapSummaryPayload.source.sourcePath` now points at the app-owned artifact for local-file intake. -- Source review of that integration found that the new core port was public inside `audio_resource.rs` but not re-exported from the crate root consumed by Tauri. `323a7fac00c4954af12b382802a9d6f8359ef4c5` is the minimal causal repair: it re-exports `copy_bounded_local_audio` without changing the admission policy or widening authority. -- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` requires a failing staging writer to produce the bounded workspace error rather than the source-media read error. -- Causal fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` replaces the ambiguous `std::io::copy` mapping with an explicit bounded read/write loop. It writes at most 100 MiB, performs a one-byte read-only probe after reaching the ceiling, preserves the media-read error for reader failures, and maps writer failures to the existing app-owned workspace error. It also adds the inverse reader-failure regression so the two diagnoses cannot collapse again. -- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` requires the Resource Admission port to return the exact staged byte count plus the SHA-256 known for bytes `01 02 03 04`. The predecessor has no such receipt API, so this is a genuine missing-contract RED rather than a mock success. -- Causal fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` adds `LocalAudioCopyReceipt` and `copy_bounded_local_audio_with_receipt`. SHA-256 is updated only after the corresponding `write_all` succeeds, so a failed writer never yields identity evidence for an incomplete stage. The compatibility byte-count adapter remains for the current Tauri caller, and `Interrupted` source reads are retried without changing the resulting identity. -- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` requires a public core reader boundary to reproduce the FIPS 180-4 `abc` SHA-256 vector. The predecessor cannot satisfy the import because its digest state is private to Resource Admission. -- Causal fix `d1ba40683772019577fec4d8c767ff8b23294e38` exports `sha256_hex_reader` from desktop core. It consumes only a caller-owned `Read`, retries `Interrupted`, propagates other I/O failures, does not open a path, and uses the same SHA-256 state as the local-audio receipt. This makes #1160 consolidation executable instead of aspirational. -- Publication-binding RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` requires an unchanged published byte stream to reproduce the staging receipt and a same-size mutation to fail with the bounded project-workspace diagnosis. The predecessor has no publication verifier. -- Causal fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` adds `verify_local_audio_publication_receipt`. It accepts only an already-open reader, reuses the same bounded staging/hash path with an in-memory sink, and requires exact byte-count plus SHA-256 equality. Read failure, growth, truncation, or content mismatch is normalized to the project-workspace error because original source admission has already completed by this boundary. -- Export repair `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exposes the publication verifier from `bandscope_desktop_core`, making the next Tauri caller integration executable without source copying. -- Verification-bound RED `6a0692ee288d3b126bd0598e07e03c88a702d567` adds a counting-reader regression for an artifact expected to be 4 bytes but grown to 8 bytes. The predecessor verifier scans all 8 bytes because it uses the global 100 MiB ceiling; the contract requires it to stop after the four expected bytes plus one growth probe. -- Causal fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` validates the native expected length, uses `expected.file_size_bytes` as the publication-read ceiling, and maps the one-byte growth probe back to the bounded workspace diagnosis. A grown published object is therefore rejected after `expected + 1` bytes rather than being hashed up to the product-wide ceiling. -- The shared SHA-256 state is checked against NIST SHA-256 known-answer vectors including the empty message, `abc`, the multi-block standard vector, and one million `a` bytes. The reader port also has interrupted-short-read and non-interrupted-failure regressions. These are correctness regressions only; they are not CAVP or module-validation evidence. - -Hosted evidence must be reacquired on the final descendant rather than transferred from any predecessor head. +The hardening chain remains cumulative and test-first where behavior changed: -## Security Notes +- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` through an ordinary non-force ancestry update. +- RED `804a2867e877947feaffb1da6c6072e6a49049fe` and fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` established exact-limit acceptance and one-byte-over rejection in the bounded-copy port. +- `a2b1bd9e33a69be75f813f005abd37345200ce55` moved successful local-file intake to an app-owned same-project stage and published `source.` only after bounded copy; `323a7fac00c4954af12b382802a9d6f8359ef4c5` exported that core port to Tauri. +- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` and fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` separated source-read from destination-write failures and made the one-byte over-limit check read-only. +- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` and fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` introduced `LocalAudioCopyReceipt { file_size_bytes, content_sha256 }` and streaming SHA-256 over successfully written bytes only. +- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` and fix `d1ba40683772019577fec4d8c767ff8b23294e38` exposed reader-only `sha256_hex_reader` so dependent contexts can reuse one implementation without acquiring path authority. +- Publication RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` and fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` introduced `verify_local_audio_publication_receipt`, requiring exact byte-count and SHA-256 equality for an already-open published reader; `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exported it. +- Bounded-verification RED `6a0692ee288d3b126bd0598e07e03c88a702d567` and fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` changed publication verification to stop after expected bytes plus one growth probe instead of hashing an invalid replacement up to the global ceiling. +- Production-integration RED `ed9fe7eba6261753dc0f68e820e2b642703fe2cd` added a focused Tauri contract requiring the actual local-audio materializer to consume both `copy_bounded_local_audio_with_receipt` and `verify_local_audio_publication_receipt`, and forbidding the compatibility byte-count-only call on that function. +- Causal production fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` switched `materialize_local_audio_source` to the receipt API, calls `sync_all` on the same-project stage, renames it to app-owned `source.`, rejects a published path observed as symlink/non-file, reopens the publication, checks descriptor length, requires `verify_local_audio_publication_receipt` equality, re-checks published path metadata, and returns the receipt's admitted byte count. -### Untrusted inputs and trust boundaries +The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Reader tests cover short reads, `Interrupted`, and non-interrupted failure. These are correctness regressions, not validation-module evidence. -The selected audio path, file metadata, and media bytes remain untrusted. The OS file dialog supplies the initial path, but the path is used only to resolve and open the user-selected source. The resulting app-owned project root is the storage trust boundary used for subsequent analysis authority. The content digest is evidence about bytes that successfully crossed that boundary into the staging writer; it is not authorization to reopen an arbitrary host path. +## Security Notes -The shared reader and publication-verification ports accept no path and create no descriptor. Callers such as Resource Admission or future Active Player stem admission must supply a descriptor they already own under their bounded-context authority. That keeps hashing reusable without turning the Shared Kernel into a filesystem service. The publication verifier additionally refuses to promote a staging receipt when the published bytes do not reproduce both its exact size and digest. +The selected audio path, file metadata, and media bytes are untrusted. The OS file dialog supplies initial user authority; BandScope uses that path only to canonicalize and open the source. The project-owned artifact is the authority used after successful admission. -### Validation and safe failure +The core hash and publication-verification ports accept no path and create no descriptor. Resource Admission or another owning context supplies an already-authorized reader. A staging receipt cannot be promoted when the published bytes do not reproduce both its length and digest. -The extension allowlist and descriptor-observed non-zero/100 MiB encoded-size policy remain unchanged. The bounded copy writes no more than 100 MiB. If exactly 100 MiB has been staged, it reads only one additional source byte to determine whether the source grew past the ceiling; that probe byte is never written or hashed as admitted content. A source read failure returns the bounded media-read message, while a destination write failure returns the bounded workspace message. `Interrupted` reads are retried. Neither failure path exposes the source path, destination path, raw OS error, media contents, or a misleading partial digest. A unique private stage is removed by the native caller on copy or flush failure. The final source artifact is not published until the staged file has been synchronized successfully. +The production Tauri caller now synchronizes the stage before rename, requires regular/non-symlink observations of the published path, opens the published object, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Any publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; no source/destination path, raw OS error, or audio bytes are exposed. -`verify_local_audio_publication_receipt` rejects an expected length of zero or greater than 100 MiB before consuming the published reader. For a valid staging receipt it reads and hashes at most the exact admitted byte count and then one probe byte. Any verification read failure, one-byte-or-greater growth, truncation, or digest mismatch fails closed as a project-workspace error and exposes no path or OS detail. The current Tauri caller has not yet been switched to this verifier, so descriptor acquisition/no-link containment and final handoff remain incomplete production integration rather than claimed behavior. +Those portable checks materially narrow linked-object substitution but are not an atomic no-follow open guarantee. A platform-specific descriptor acquisition design remains necessary if BandScope needs `O_NOFOLLOW`/reparse-point-equivalent race semantics against a same-user adversary. The parent directory is also not yet explicitly synchronized after rename, so power-loss durability of the directory entry is not claimed here. -### Logging and privacy +No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt is non-secret content identity. It is not yet part of the current bootstrap/persistence wire contract. -No new logging, telemetry, network transfer, or path exposure is introduced. SHA-256 is persisted only as non-secret content identity when the Project Persistence owner consumes the receipt; the current slice does not log it. The original filename remains a user-facing label already present in the bootstrap contract; the original absolute path is no longer the local-analysis source path after successful admission. +## Test and acceptance points -### Test points +- exact 100 MiB encoded-byte limit accepted; one byte over rejected; +- empty source rejected; +- source-reader failure keeps the selected-audio diagnosis; +- destination-writer failure keeps the project-workspace diagnosis; +- `Interrupted` reads retry without changing identity; +- failed writes cannot return a partial receipt; +- the growth probe is neither staged nor included in the receipt digest; +- SHA-256 standard vectors and chunked/short-read paths agree; +- unchanged published bytes reproduce the staging receipt; +- same-size content mutation, truncation, growth, or publication-read failure fails closed; +- invalid receipt lengths fail before reading publication bytes; +- grown publication is rejected after expected bytes plus one probe; +- production Tauri local-file materialization must compile against and call the receipt and publication-verification ports, not the compatibility byte-count adapter; +- hosted Rust/Tauri, Windows, macOS, security, SBOM, coverage/package, and independent-review evidence must be reacquired on the final exact #866 head. -- exact encoded-byte limit remains accepted; -- one-byte-over growth is rejected without staging or hashing the probe byte; -- empty source remains rejected; -- reader failure retains the bounded selected-audio diagnosis; -- transient interrupted reads are retried and preserve the expected digest; -- destination writer failure reports the bounded app-owned workspace diagnosis and cannot return a partial receipt; -- SHA-256 matches authoritative known-answer vectors across short, multi-block, chunked, and one-million-byte inputs; -- the public reader boundary reproduces the same digest, retries interrupted reads, and propagates non-interrupted reader failure; -- an unchanged published reader reproduces the staging receipt; -- same-size published-content mutation is rejected even when byte count is unchanged; -- publication-verification read failure is normalized to the bounded project-workspace diagnosis; -- a grown published artifact is rejected after the expected byte count plus one probe byte rather than scanning unrelated tail bytes up to 100 MiB; -- invalid expected publication lengths fail before consuming published bytes; -- failed copy or flush does not publish the final project-owned source artifact; -- Tauri must compile against the exported Resource Admission, publication-verification, and shared SHA-256 ports; -- hosted Rust/Tauri, Windows, macOS, security, SBOM, and review gates must be reacquired on the final exact PR head. +Synthetic arrays or source-text checks do not substitute for the later production scientific acceptance requirement. Rights-cleared real decoded audio still has to exercise the integrated Windows/macOS intake/decode/analysis/playback path where the relevant commercial claim is made. ## Remaining risks and follow-up -The core can now emit native streaming identity for exactly the bytes successfully staged and can verify that an already-open published byte stream reproduces that receipt without scanning beyond the expected artifact plus one growth probe. The current Tauri `materialize_local_audio_source` caller still uses the compatibility byte-count adapter, so this run does not claim production publication binding complete. The next owner slice is to switch that native caller to `copy_bounded_local_audio_with_receipt`, synchronize and publish `source.`, open the published object under app-owned/no-link authority, call `verify_local_audio_publication_receipt`, and only then expose the path-free identity fields needed by #970/#962. Reopen must resolve only the app-owned artifact, revalidate regular/no-link containment, observed size, digest, and decode admission, and reconstruct a fresh bootstrap before #1160 mints playback authority. +The local-file production path now binds bootstrap authority to a publication whose bytes reproduce the native staging receipt. The next cross-context step is **not** another copy or hash implementation: #866 must expose a path-free publication-identity receipt suitable for #970 v3, while the analysis engine may continue to consume its narrower runtime `LocalAudioSource` path metadata. The current Rust/TypeScript/Python analysis `LocalAudioSource` contract does not include `contentSha256`, so injecting a new field into that runtime request without a versioned contract change would break strict Python admission. A distinct bootstrap/persistence source-identity boundary is therefore preferred. + +After Project Persistence receives `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. -The private playable-stem SHA-256 implementation already present in #1160 is now a concrete consolidation finding with a consumable replacement port: when this Resource Admission foundation is available in that stack, #1160 must replace its local implementation with `bandscope_desktop_core::sha256_hex_reader` while retaining stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Parent-directory durability and exhaustive power-loss injection remain separate recovery work. Issue #1129 remains the commercial decoder dependency gate and is not changed by this materialization boundary. +When #866 enters the #1160 ancestry, the private playable-stem SHA-256 implementation should be deleted in favor of `bandscope_desktop_core::sha256_hex_reader` while preserving its stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Platform-atomic no-follow acquisition and parent-directory crash durability remain Resource Admission/platform work. Issue #1129 remains the commercial decoder-dependency gate. ## References From 51734ced625fda773f30d874878e31905d602334 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:29:06 +0900 Subject: [PATCH 298/448] test(audio): align native oversize policy expectation --- apps/desktop/core/tests/audio_resource_policy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/audio_resource_policy.rs b/apps/desktop/core/tests/audio_resource_policy.rs index 11c6725ca..163e49d50 100644 --- a/apps/desktop/core/tests/audio_resource_policy.rs +++ b/apps/desktop/core/tests/audio_resource_policy.rs @@ -20,6 +20,6 @@ fn local_audio_size_policy_rejects_an_empty_native_bootstrap_source() { fn local_audio_size_policy_rejects_a_native_source_above_the_canonical_ceiling() { assert_eq!( validate_local_audio_file_size(MAX_LOCAL_AUDIO_FILE_BYTES + 1), - Err("Selected audio file exceeds the 100 MiB analysis limit.".to_string()) + Err("Choose a shorter or smaller song file to start analysis.".to_string()) ); } From dd78dee713c2ee27598dddb08c5db5c4199f7731 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:31:22 +0900 Subject: [PATCH 299/448] test(audio): keep zero-byte guard on decode port --- .../tests/test_audio_resource_policy_coverage_regressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py index f45e5d454..8dfa3d688 100644 --- a/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py +++ b/services/analysis-engine/tests/test_audio_resource_policy_coverage_regressions.py @@ -66,7 +66,7 @@ def fail_if_decoder_runs(*_args: object, **_kwargs: object) -> tuple[np.ndarray, raise AssertionError("zero-byte input must be rejected before decoder invocation") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.librosa.load", + "bandscope_analysis.audio_decode.librosa.load", fail_if_decoder_runs, ) From 6ef0096aa0e09f293812cda384737bc1c42f9a4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:32:47 +0900 Subject: [PATCH 300/448] test(audio): patch canonical decode boundary --- .../analysis-engine/tests/test_separation.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index 649fb0f23..b22503520 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -39,22 +39,22 @@ def test_categorize_role_bass() -> None: def test_categorize_role_keys() -> None: - """Test keyboard role is categorized correctly.""" + """Test keyboard role type is categorized correctly.""" assert _categorize_role("keys-right", "Keyboard 1 Right Hand", "hand") == StemCategory.KEYS def test_categorize_role_piano() -> None: - """Test piano role is categorized correctly.""" + """Test piano role type is categorized correctly.""" assert _categorize_role("piano-1", "Piano", "instrument") == StemCategory.KEYS def test_categorize_role_guitar() -> None: - """Test guitar role is categorized correctly.""" + """Test guitar role type is categorized correctly.""" assert _categorize_role("guitar-1", "Electric Guitar", "instrument") == StemCategory.GUITAR def test_categorize_role_drums() -> None: - """Test drum role is categorized correctly.""" + """Test drum role type is categorized correctly.""" assert _categorize_role("drum-kit", "Drum Kit", "instrument") == StemCategory.DRUMS @@ -466,11 +466,11 @@ def test_audio_stem_separator_rejects_empty_decoder_output( audio_path = tmp_path / "empty.wav" audio_path.write_bytes(b"placeholder") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + "bandscope_analysis.audio_decode.preflight_audio_metadata", lambda *_args, **_kwargs: None, ) monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.librosa.load", + "bandscope_analysis.audio_decode.librosa.load", lambda *args, **kwargs: (np.array([], dtype=np.float32), 8_000), ) separator = AudioStemSeparator(AudioSeparationConfig(target_sample_rate=8_000)) @@ -486,7 +486,7 @@ def test_audio_stem_separator_redacts_decoder_exceptions( audio_path = tmp_path / "broken.wav" audio_path.write_bytes(b"placeholder") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.preflight_audio_metadata", + "bandscope_analysis.audio_decode.preflight_audio_metadata", lambda *_args, **_kwargs: None, ) @@ -494,7 +494,7 @@ def fail_decode(*args, **kwargs): raise RuntimeError(f"decoder failed under {tmp_path}") monkeypatch.setattr( - "bandscope_analysis.separation.audio_separator.librosa.load", + "bandscope_analysis.audio_decode.librosa.load", fail_decode, ) separator = AudioStemSeparator(AudioSeparationConfig(target_sample_rate=8_000)) From e5726df3985e6165aff9119d7fb9a24734d433b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:33:56 +0900 Subject: [PATCH 301/448] test(audio): preserve unrelated separation test wording --- services/analysis-engine/tests/test_separation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py index b22503520..49ac97637 100644 --- a/services/analysis-engine/tests/test_separation.py +++ b/services/analysis-engine/tests/test_separation.py @@ -39,22 +39,22 @@ def test_categorize_role_bass() -> None: def test_categorize_role_keys() -> None: - """Test keyboard role type is categorized correctly.""" + """Test keyboard role is categorized correctly.""" assert _categorize_role("keys-right", "Keyboard 1 Right Hand", "hand") == StemCategory.KEYS def test_categorize_role_piano() -> None: - """Test piano role type is categorized correctly.""" + """Test piano role is categorized correctly.""" assert _categorize_role("piano-1", "Piano", "instrument") == StemCategory.KEYS def test_categorize_role_guitar() -> None: - """Test guitar role type is categorized correctly.""" + """Test guitar role is categorized correctly.""" assert _categorize_role("guitar-1", "Electric Guitar", "instrument") == StemCategory.GUITAR def test_categorize_role_drums() -> None: - """Test drum role type is categorized correctly.""" + """Test drum role is categorized correctly.""" assert _categorize_role("drum-kit", "Drum Kit", "instrument") == StemCategory.DRUMS From 46ca91affe2fc888dd84a9eabf1a9c193973bcd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:34:36 +0900 Subject: [PATCH 302/448] docs(security): align local-audio publication authority --- docs/security/app-security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security/app-security.md b/docs/security/app-security.md index bd50f0a00..d7250bbf5 100644 --- a/docs/security/app-security.md +++ b/docs/security/app-security.md @@ -141,7 +141,7 @@ Every boundary crossing requires validation, scope restriction, minimal logging, - Before any decoder resamples, downmixes, or duration-truncates local audio, inspect source-container metadata from the already-open handle with `soundfile.info`, enforce the shared 8 kHz–192 kHz and mono/stereo source contract, reject overlong sources, and rewind the handle before `librosa.load`. - In the Python analysis boundary, reject decoded audio that is empty, non-finite, wrong-rate, wrong-shaped, or over the accepted sample budget before beat tracking or model inference. Use the one-sample-over decode probe described in `docs/doctoring/audio-resource-policy.md` so an exact-boundary track remains accepted while excess decoded output is observable and fails closed. - Do not add arbitrary filesystem scanning just to find media files. -- When bootstrapping a project around local audio, prefer referencing the validated original file plus app-owned temp/cache/project roots over copying the file until persistence requirements justify the extra storage boundary. +- When bootstrapping a project around local audio, use the OS-selected external file only as untrusted admission input. Stage and sync admitted bytes under the app-owned project root, publish them as `source.`, then reopen and verify the published regular/non-symlink object against the bounded size and SHA-256 receipt before analysis or persistence. Do not persist an arbitrary external absolute path as authority. ### YouTube and remote URL import From 45b1f72abeded4e478775d31085244621f68c9f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:36:37 +0900 Subject: [PATCH 303/448] test(audio): require no-clobber source publication --- .../tests/local_audio_publication_contract.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index 2b8e87f14..d23fcdf6c 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -23,3 +23,29 @@ fn local_audio_materializer_consumes_publication_bound_receipt() { "the compatibility byte-count-only adapter must not remain on the production publication path" ); } + +#[test] +fn local_audio_publication_must_not_overwrite_an_existing_source_name() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("std::fs::hard_link(&stage, &destination)"), + "publication must use an atomic no-clobber filesystem create instead of check-then-rename" + ); + assert!( + !materializer.contains("destination.exists()"), + "a preflight existence check is racy and must not authorize a later overwrite-capable rename" + ); + assert!( + !materializer.contains("std::fs::rename(&stage, &destination)"), + "overwrite-capable rename must not publish the immutable project source" + ); +} From eb972e951ef090c92b595c752b18d66f11f6b96e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:39:41 +0900 Subject: [PATCH 304/448] fix(audio): publish local source without clobber race --- apps/desktop/src-tauri/src/main.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 729b6c581..d614f79e8 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -146,14 +146,15 @@ fn app_owned_root( /// Security Notes: the external path is used only to canonicalize and open the /// user-authorized source. Size is checked from that opened descriptor, bytes /// are copied through the bounded Resource Admission helper into a private -/// same-project staging file, and a synchronized stage is renamed to -/// `source.`. The published object is then required to remain a -/// regular non-symlink filesystem entry and its opened bytes must reproduce the -/// staging size+SHA-256 receipt before bootstrap authority is returned. This -/// keeps later analysis bound to the app-owned publication rather than the -/// mutable user-selected path. Atomic no-follow descriptor acquisition remains -/// a separate platform-hardening requirement; these portable checks do not -/// claim to provide O_NOFOLLOW-equivalent race semantics. +/// same-project staging file. After the stage is synchronized, publication uses +/// a same-filesystem hard link so an existing `source.` name cannot +/// be overwritten; the private stage name is then removed. The published object +/// is required to remain a regular non-symlink filesystem entry and its opened +/// bytes must reproduce the staging size+SHA-256 receipt before bootstrap +/// authority is returned. This keeps later analysis bound to the app-owned +/// publication rather than the mutable user-selected path. Atomic no-follow +/// descriptor acquisition remains a separate platform-hardening requirement; +/// these portable checks do not claim O_NOFOLLOW-equivalent race semantics. fn materialize_local_audio_source( path: &Path, project_root: &Path, @@ -207,11 +208,12 @@ fn materialize_local_audio_source( } drop(staged); - if destination.exists() { + if std::fs::hard_link(&stage, &destination).is_err() { let _ = std::fs::remove_file(&stage); return Err("Could not prepare the local project workspace.".to_string()); } - if std::fs::rename(&stage, &destination).is_err() { + if std::fs::remove_file(&stage).is_err() { + let _ = std::fs::remove_file(&destination); let _ = std::fs::remove_file(&stage); return Err("Could not prepare the local project workspace.".to_string()); } From 55b0da5abd5cf252c256d2cca2fc57b2d91ddab6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:42:10 +0900 Subject: [PATCH 305/448] docs(audio): record no-clobber publication decision --- .../local-audio-source-materialization.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index 756c02149..f85585e1e 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -14,9 +14,10 @@ The implementation accumulated several narrower defects while that boundary was - SHA-256 existed in more than one security-sensitive implementation and initially had no reusable reader-only core port; - a staging receipt alone did not prove that the final published object still contained the same bytes; - publication verification initially used the product-wide 100 MiB ceiling rather than the receipt's tighter expected length; -- after the core receipt and verifier existed, the production Tauri materializer still called the compatibility byte-count-only adapter and discarded SHA-256 evidence. +- after the core receipt and verifier existed, the production Tauri materializer still called the compatibility byte-count-only adapter and discarded SHA-256 evidence; +- publication initially used `destination.exists()` followed by overwrite-capable `rename`, leaving a check-then-act window where another entry could appear at `source.` between the check and publication. -The last item is now repaired on the canonical #866 branch: the production local-file materializer consumes the native receipt and re-verifies the published app-owned object before returning bootstrap authority. Path-free digest handoff into #970, restart re-admission, platform-atomic no-follow acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. +The receipt/verifier and no-clobber publication defects are now repaired on the canonical #866 branch: the production local-file materializer consumes the native receipt, synchronizes the stage, creates the app-owned publication with a same-filesystem hard link that fails if the destination name already exists, removes the private stage name, and re-verifies the published object before returning bootstrap authority. Path-free digest handoff into #970, restart re-admission, platform-atomic no-follow acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. ## Constraints @@ -30,6 +31,7 @@ The last item is now repaired on the canonical #866 branch: the production local - SHA-256 is content-identity/correctness evidence only. This code does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against an actor who can replace both artifact and stored digest. - Reusable SHA-256 and publication-verification APIs accept only caller-owned `Read` values. They do not open arbitrary paths or create filesystem authority. - Publication verification rejects an invalid native receipt length before reading and consumes at most `expected.file_size_bytes + 1` bytes. +- Publication must not overwrite an existing app-owned source name. The same-project staging file and destination share a filesystem; hard-link publication therefore provides a narrow no-clobber create, while unsupported filesystems fail closed rather than falling back to overwrite-capable rename. - The selected filename may remain a user-facing label, but local-analysis authority moves to app-owned storage. - Portable `symlink_metadata` / open / re-check logic narrows linked-object substitution but does not claim atomic `O_NOFOLLOW` or Windows reparse-point-equivalent semantics. - This slice does not claim that the digest is already persisted in `.bscope`, restart/reopen is complete, YouTube persistence is complete, parent-directory publication is crash-durable, or the commercial decoder-license gate is solved. @@ -45,6 +47,8 @@ The last item is now repaired on the canonical #866 branch: the production local 7. Treat the staging receipt as publication truth without rereading — rejected. Same-size mutation would evade byte-count checks. 8. Re-read every published object up to 100 MiB — rejected. The native receipt supplies a tighter expected length, so verification reads only expected bytes plus one growth probe. 9. Leave the Tauri caller on `copy_bounded_local_audio -> u64` — rejected. Production publication must retain the native receipt, synchronize and publish the stage, reopen the app-owned object, and require exact size+SHA-256 equality before bootstrap authority is returned. +10. Check `destination.exists()` and then rename the stage — rejected. On platforms where rename replaces an existing target, the check and rename form a race that can clobber an entry created after the check. +11. Create the destination with `std::fs::hard_link(stage, destination)` and then remove the private stage name — selected for this same-filesystem project root. The create fails when the destination already exists and preserves the synchronized bytes without a second copy. Failure to create or remove the stage fails closed and does not fall back to overwrite-capable rename. ## Implementation and exact evidence @@ -59,7 +63,9 @@ The hardening chain remains cumulative and test-first where behavior changed: - Publication RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` and fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` introduced `verify_local_audio_publication_receipt`, requiring exact byte-count and SHA-256 equality for an already-open published reader; `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exported it. - Bounded-verification RED `6a0692ee288d3b126bd0598e07e03c88a702d567` and fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` changed publication verification to stop after expected bytes plus one growth probe instead of hashing an invalid replacement up to the global ceiling. - Production-integration RED `ed9fe7eba6261753dc0f68e820e2b642703fe2cd` added a focused Tauri contract requiring the actual local-audio materializer to consume both `copy_bounded_local_audio_with_receipt` and `verify_local_audio_publication_receipt`, and forbidding the compatibility byte-count-only call on that function. -- Causal production fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` switched `materialize_local_audio_source` to the receipt API, calls `sync_all` on the same-project stage, renames it to app-owned `source.`, rejects a published path observed as symlink/non-file, reopens the publication, checks descriptor length, requires `verify_local_audio_publication_receipt` equality, re-checks published path metadata, and returns the receipt's admitted byte count. +- Causal production fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` switched `materialize_local_audio_source` to the receipt API, calls `sync_all` on the same-project stage, publishes app-owned `source.`, rejects a published path observed as symlink/non-file, reopens the publication, checks descriptor length, requires `verify_local_audio_publication_receipt` equality, re-checks published path metadata, and returns the receipt's admitted byte count. +- No-clobber RED `45b1f72abeded4e478775d31085244621f68c9f0` requires production publication to use an atomic no-clobber destination create and explicitly forbids the `destination.exists()` plus overwrite-capable `rename` sequence. +- No-clobber fix `eb972e951ef090c92b595c752b18d66f11f6b96e` replaces check-then-rename with same-filesystem `hard_link(stage, destination)`, removes the private stage name only after the destination link exists, and fails closed if either publication or stage cleanup cannot complete. The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Reader tests cover short reads, `Interrupted`, and non-interrupted failure. These are correctness regressions, not validation-module evidence. @@ -69,9 +75,9 @@ The selected audio path, file metadata, and media bytes are untrusted. The OS fi The core hash and publication-verification ports accept no path and create no descriptor. Resource Admission or another owning context supplies an already-authorized reader. A staging receipt cannot be promoted when the published bytes do not reproduce both its length and digest. -The production Tauri caller now synchronizes the stage before rename, requires regular/non-symlink observations of the published path, opens the published object, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Any publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; no source/destination path, raw OS error, or audio bytes are exposed. +The production Tauri caller now synchronizes the stage, creates the destination through a no-clobber same-filesystem hard link, removes the private stage name, requires regular/non-symlink observations of the published path, opens the published object, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Any publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; no source/destination path, raw OS error, or audio bytes are exposed. If hard-link creation is unavailable on the project filesystem, admission fails closed rather than silently downgrading to an overwrite-capable publication primitive. -Those portable checks materially narrow linked-object substitution but are not an atomic no-follow open guarantee. A platform-specific descriptor acquisition design remains necessary if BandScope needs `O_NOFOLLOW`/reparse-point-equivalent race semantics against a same-user adversary. The parent directory is also not yet explicitly synchronized after rename, so power-loss durability of the directory entry is not claimed here. +Those portable checks materially narrow name clobbering and linked-object substitution but are not an atomic no-follow open guarantee. A platform-specific descriptor acquisition design remains necessary if BandScope needs `O_NOFOLLOW`/reparse-point-equivalent race semantics against a same-user adversary. The parent directory is also not yet explicitly synchronized after destination-link creation and stage unlink, so power-loss durability of the directory entries is not claimed here. No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt is non-secret content identity. It is not yet part of the current bootstrap/persistence wire contract. @@ -89,6 +95,7 @@ No new logging, telemetry, network transfer, or raw-media export is introduced. - same-size content mutation, truncation, growth, or publication-read failure fails closed; - invalid receipt lengths fail before reading publication bytes; - grown publication is rejected after expected bytes plus one probe; +- production publication cannot use an existence-check plus overwrite-capable rename and must fail closed when the fixed destination name already exists; - production Tauri local-file materialization must compile against and call the receipt and publication-verification ports, not the compatibility byte-count adapter; - hosted Rust/Tauri, Windows, macOS, security, SBOM, coverage/package, and independent-review evidence must be reacquired on the final exact #866 head. @@ -96,7 +103,7 @@ Synthetic arrays or source-text checks do not substitute for the later productio ## Remaining risks and follow-up -The local-file production path now binds bootstrap authority to a publication whose bytes reproduce the native staging receipt. The next cross-context step is **not** another copy or hash implementation: #866 must expose a path-free publication-identity receipt suitable for #970 v3, while the analysis engine may continue to consume its narrower runtime `LocalAudioSource` path metadata. The current Rust/TypeScript/Python analysis `LocalAudioSource` contract does not include `contentSha256`, so injecting a new field into that runtime request without a versioned contract change would break strict Python admission. A distinct bootstrap/persistence source-identity boundary is therefore preferred. +The local-file production path now binds bootstrap authority to a no-clobber app-owned publication whose bytes reproduce the native staging receipt. The next cross-context step is **not** another copy or hash implementation: #866 must expose a path-free publication-identity receipt suitable for #970 v3, while the analysis engine may continue to consume its narrower runtime `LocalAudioSource` path metadata. The current Rust/TypeScript/Python analysis `LocalAudioSource` contract does not include `contentSha256`, so injecting a new field into that runtime request without a versioned contract change would break strict Python admission. A distinct bootstrap/persistence source-identity boundary is therefore preferred. After Project Persistence receives `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. From bad908c83bfb89f545f0f2f637d96ac8fdfa3e0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:03:12 +0900 Subject: [PATCH 306/448] test(audio): require path-free publication identity handoff --- .../tests/local_audio_publication_identity.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/desktop/core/tests/local_audio_publication_identity.rs diff --git a/apps/desktop/core/tests/local_audio_publication_identity.rs b/apps/desktop/core/tests/local_audio_publication_identity.rs new file mode 100644 index 000000000..cd3b5fb63 --- /dev/null +++ b/apps/desktop/core/tests/local_audio_publication_identity.rs @@ -0,0 +1,77 @@ +use bandscope_desktop_core::{ + build_local_audio_publication_identity, LocalAudioCopyReceipt, +}; + +fn receipt() -> LocalAudioCopyReceipt { + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a".to_string(), + } +} + +#[test] +fn publication_identity_is_path_free_and_deterministic() { + let identity = build_local_audio_publication_identity("project-1-1", "wav", &receipt()) + .expect("verified publication evidence should become a durable path-free identity"); + + assert_eq!(identity.project_id, "project-1-1"); + assert_eq!(identity.artifact_name, "source.wav"); + assert_eq!(identity.extension, "wav"); + assert_eq!(identity.file_size_bytes, 4); + assert_eq!( + identity.content_sha256, + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + + let json = serde_json::to_value(&identity).expect("publication identity should serialize"); + assert_eq!( + json, + serde_json::json!({ + "projectId": "project-1-1", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4, + "contentSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + }) + ); + assert!(json.get("sourcePath").is_none()); + assert!(json.get("path").is_none()); +} + +#[test] +fn publication_identity_rejects_noncanonical_or_fabricated_evidence() { + for (project_id, extension, receipt) in [ + ("../project-1-1", "wav", receipt()), + ("project-1-1", "WAV", receipt()), + ("project-1-1", "exe", receipt()), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 0, + content_sha256: "00".repeat(32), + }, + ), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: "AA".repeat(32), + }, + ), + ( + "project-1-1", + "wav", + LocalAudioCopyReceipt { + file_size_bytes: 4, + content_sha256: "not-a-sha256".to_string(), + }, + ), + ] { + let error = build_local_audio_publication_identity(project_id, extension, &receipt) + .expect_err("only canonical native publication evidence may cross persistence handoff"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} From 87bdeea92d3bb6dc45eb666f422bd8a3d36f3872 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:03:25 +0900 Subject: [PATCH 307/448] fix(audio): expose path-free publication identity --- apps/desktop/core/src/publication_identity.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/desktop/core/src/publication_identity.rs diff --git a/apps/desktop/core/src/publication_identity.rs b/apps/desktop/core/src/publication_identity.rs new file mode 100644 index 000000000..0d984b2a0 --- /dev/null +++ b/apps/desktop/core/src/publication_identity.rs @@ -0,0 +1,83 @@ +use crate::{ + audio_resource::{LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES}, + runtime_core::{is_valid_project_id, AUDIO_EXTENSIONS}, +}; +use serde::{Deserialize, Serialize}; + +const LOCAL_AUDIO_PUBLICATION_IDENTITY_ERROR: &str = + "Could not prepare the local project workspace."; + +/// Path-free native identity for one verified app-owned local-audio publication. +/// +/// This value is suitable for Project Persistence handoff because it names only +/// a BandScope-owned artifact and carries the exact native size/digest evidence +/// produced by Resource Admission. It never contains an external or absolute +/// filesystem path. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAudioPublicationIdentity { + /// Locally minted BandScope project id that owns the publication. + pub project_id: String, + /// Deterministic app-owned artifact name within that project. + pub artifact_name: String, + /// Canonical lowercase admitted audio extension. + pub extension: String, + /// Exact number of bytes in the verified publication. + pub file_size_bytes: u64, + /// Lowercase SHA-256 of the exact verified publication bytes. + pub content_sha256: String, +} + +fn is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Build the durable path-free identity for verified local-audio publication evidence. +/// +/// Security Notes: callers must supply a project id minted under BandScope's +/// existing project-id grammar and the canonical lowercase extension that was +/// admitted by Resource Admission. The receipt must come from the verified +/// publication path, not renderer input. Invalid ids, extensions, sizes, or +/// digest encodings fail closed with the bounded project-workspace diagnosis. +pub fn build_local_audio_publication_identity( + project_id: &str, + extension: &str, + receipt: &LocalAudioCopyReceipt, +) -> Result { + if !is_valid_project_id(project_id) + || !AUDIO_EXTENSIONS.contains(&extension) + || extension.bytes().any(|byte| byte.is_ascii_uppercase()) + || receipt.file_size_bytes == 0 + || receipt.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES + || !is_lowercase_sha256(&receipt.content_sha256) + { + return Err(LOCAL_AUDIO_PUBLICATION_IDENTITY_ERROR.to_string()); + } + + Ok(LocalAudioPublicationIdentity { + project_id: project_id.to_string(), + artifact_name: format!("source.{extension}"), + extension: extension.to_string(), + file_size_bytes: receipt.file_size_bytes, + content_sha256: receipt.content_sha256.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_sha256_requires_exact_canonical_encoding() { + assert!(is_lowercase_sha256( + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + )); + assert!(!is_lowercase_sha256(&"a".repeat(63))); + assert!(!is_lowercase_sha256(&"a".repeat(65))); + assert!(!is_lowercase_sha256(&"A".repeat(64))); + assert!(!is_lowercase_sha256(&"g".repeat(64))); + } +} From 344a9a39f32ac40b3e137c76e2cfd46243827bb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:03:31 +0900 Subject: [PATCH 308/448] fix(audio): export publication identity handoff --- apps/desktop/core/src/root.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 2d3eefc85..56df05035 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -9,6 +9,7 @@ mod runtime_core; mod audio_resource; mod content_sha256; +mod publication_identity; mod score_pdf; pub use audio_resource::{ @@ -17,5 +18,8 @@ pub use audio_resource::{ LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, }; pub use content_sha256::sha256_hex_reader; +pub use publication_identity::{ + build_local_audio_publication_identity, LocalAudioPublicationIdentity, +}; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; From 681675d5a51771f34f18d8eef949990d347e344a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:05:15 +0900 Subject: [PATCH 309/448] docs(audio): record path-free publication identity boundary --- .../local-audio-source-materialization.md | 116 +++++++++--------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index f85585e1e..d0dac8f89 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -4,22 +4,25 @@ BandScope originally validated an OS-selected local audio file and then let later analysis reopen the canonical external filesystem path. That left analysis and restart dependent on mutable host authority: the selected file could be moved, replaced, truncated, or grown after admission. Project Persistence #962 also needs a durable source identity that does not serialize an arbitrary user filesystem path. -Resource Admission & Decode therefore owns creation and verification of the app-owned `source.` artifact. Project Persistence owns the later versioned reference that consumes native evidence from that artifact; it does not copy or hash user media itself. +Resource Admission & Decode therefore owns creation and verification of the app-owned `source.` artifact and the native content identity for that publication. Project Persistence owns the later versioned project reference that consumes this evidence; it does not copy or hash user media itself. -The implementation accumulated several narrower defects while that boundary was being hardened: +The hardening sequence exposed distinct defects: -- `std::io::copy` collapsed source-read and app-owned destination-write failures into the same buyer diagnosis; +- source-read and app-owned destination-write failures were initially collapsed into one diagnosis; - the one-byte over-limit probe was initially written into the disposable stage; -- the bounded copy returned only a byte count, so persistence had no native identity for the exact bytes written; +- the bounded copy returned only a byte count, so there was no native identity for the exact bytes written; - SHA-256 existed in more than one security-sensitive implementation and initially had no reusable reader-only core port; - a staging receipt alone did not prove that the final published object still contained the same bytes; -- publication verification initially used the product-wide 100 MiB ceiling rather than the receipt's tighter expected length; -- after the core receipt and verifier existed, the production Tauri materializer still called the compatibility byte-count-only adapter and discarded SHA-256 evidence; -- publication initially used `destination.exists()` followed by overwrite-capable `rename`, leaving a check-then-act window where another entry could appear at `source.` between the check and publication. +- publication verification initially read against the product-wide 100 MiB ceiling instead of the receipt's tighter expected length; +- the production Tauri materializer initially discarded the receipt and stayed on the byte-count-only adapter; +- publication initially used `destination.exists()` followed by overwrite-capable `rename`, creating a check-then-act clobber window; +- even after publication verification existed, Project Persistence still had no typed path-free handoff value for `projectId + artifactName + extension + fileSizeBytes + contentSha256`. -The receipt/verifier and no-clobber publication defects are now repaired on the canonical #866 branch: the production local-file materializer consumes the native receipt, synchronizes the stage, creates the app-owned publication with a same-filesystem hard link that fails if the destination name already exists, removes the private stage name, and re-verifies the published object before returning bootstrap authority. Path-free digest handoff into #970, restart re-admission, platform-atomic no-follow acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. +The canonical #866 branch now repairs those defects through the path-free identity type. Production local-file materialization already consumes the native receipt, synchronizes the stage, publishes with a same-filesystem no-clobber hard link, removes the private stage name, and verifies the published bytes before returning bootstrap authority. `LocalAudioPublicationIdentity` now supplies the separate durable evidence shape for Project Persistence without changing the strict analysis-runtime `LocalAudioSource` wire. -## Constraints +The remaining production integration is narrower: the Tauri local-file command still has to construct and retain the new path-free identity from its already verified receipt, then expose it only through the native persistence boundary consumed by #970. Restart re-admission, platform-atomic no-follow descriptor acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. + +## Constraints and invariants - Local analysis remains local-first; this boundary adds no network authority. - Renderer input never selects an arbitrary analysis or persistence path. @@ -29,85 +32,86 @@ The receipt/verifier and no-clobber publication defects are now repaired on the - `Interrupted` reads are retried. - SHA-256 covers only byte slices whose staging writes succeeded. The one-byte growth probe is not admitted content and is not hashed into the receipt. - SHA-256 is content-identity/correctness evidence only. This code does not claim CAVP validation, FIPS 140 validation, authenticity, or protection against an actor who can replace both artifact and stored digest. -- Reusable SHA-256 and publication-verification APIs accept only caller-owned `Read` values. They do not open arbitrary paths or create filesystem authority. -- Publication verification rejects an invalid native receipt length before reading and consumes at most `expected.file_size_bytes + 1` bytes. -- Publication must not overwrite an existing app-owned source name. The same-project staging file and destination share a filesystem; hard-link publication therefore provides a narrow no-clobber create, while unsupported filesystems fail closed rather than falling back to overwrite-capable rename. -- The selected filename may remain a user-facing label, but local-analysis authority moves to app-owned storage. +- Reusable SHA-256 and publication-verification APIs accept caller-owned `Read` values and acquire no path authority. +- Publication verification consumes at most `expected.file_size_bytes + 1` bytes and rejects invalid expected lengths before reading. +- Publication must not overwrite an existing app-owned source name. Same-project hard-link publication fails closed when the destination exists or the filesystem cannot provide that primitive; it does not fall back to overwrite-capable rename. +- The analysis-runtime `LocalAudioSource` contract remains `sourcePath + fileName + extension + fileSizeBytes`. `contentSha256` is not injected into that strict Rust/TypeScript/Python request without a versioned contract change. +- The persistence identity is a distinct contract. It contains exactly `projectId + artifactName + extension + fileSizeBytes + contentSha256`; it contains no `path` or `sourcePath` field. +- The persistence identity accepts only an existing BandScope project-id grammar, canonical lowercase admitted extension, byte size `1..=100 MiB`, and exactly 64 lowercase hexadecimal SHA-256 characters. `artifactName` is derived as `source.` rather than accepted from renderer input. - Portable `symlink_metadata` / open / re-check logic narrows linked-object substitution but does not claim atomic `O_NOFOLLOW` or Windows reparse-point-equivalent semantics. -- This slice does not claim that the digest is already persisted in `.bscope`, restart/reopen is complete, YouTube persistence is complete, parent-directory publication is crash-durable, or the commercial decoder-license gate is solved. +- The parent project directory is not yet explicitly synchronized after destination-link creation and stage unlink, so power-loss durability of the directory entries is not claimed. ## Decision record 1. Keep the external canonical path and revalidate before every analysis — rejected. Restart and persistence would still depend on mutable host authority. -2. Persist the absolute external path — rejected. It widens disclosure and violates the path-free #962 direction. -3. Copy the selected file into a project-owned `source.` artifact — selected. This gives later analysis a stable app-owned authority. -4. Keep `std::io::copy` and one generic error — rejected. Explicit bounded read/write preserves the same ceiling while separating source and destination failures. -5. Hash later in the renderer or from the original path — rejected. Neither is authoritative for the bytes actually staged into BandScope storage. -6. Add another SHA-256 implementation in persistence or Active Player — rejected. `bandscope_desktop_core::sha256_hex_reader` is the minimal reader-only Shared Kernel. +2. Persist the absolute external path — rejected. It widens disclosure and violates #962's path-free direction. +3. Copy the selected file into app-owned `source.` — selected. Later analysis can use BandScope-owned authority. +4. Keep `std::io::copy` and one generic error — rejected. Explicit bounded read/write preserves the ceiling while distinguishing source and destination failures. +5. Hash later in the renderer or from the original path — rejected. Neither is authoritative for bytes actually staged into BandScope storage. +6. Add another SHA-256 implementation in persistence or Active Player — rejected. `bandscope_desktop_core::sha256_hex_reader` is the reader-only Shared Kernel. 7. Treat the staging receipt as publication truth without rereading — rejected. Same-size mutation would evade byte-count checks. -8. Re-read every published object up to 100 MiB — rejected. The native receipt supplies a tighter expected length, so verification reads only expected bytes plus one growth probe. -9. Leave the Tauri caller on `copy_bounded_local_audio -> u64` — rejected. Production publication must retain the native receipt, synchronize and publish the stage, reopen the app-owned object, and require exact size+SHA-256 equality before bootstrap authority is returned. -10. Check `destination.exists()` and then rename the stage — rejected. On platforms where rename replaces an existing target, the check and rename form a race that can clobber an entry created after the check. -11. Create the destination with `std::fs::hard_link(stage, destination)` and then remove the private stage name — selected for this same-filesystem project root. The create fails when the destination already exists and preserves the synchronized bytes without a second copy. Failure to create or remove the stage fails closed and does not fall back to overwrite-capable rename. +8. Re-read every published object up to 100 MiB — rejected. The native receipt gives a tighter expected length. +9. Leave the Tauri caller on `copy_bounded_local_audio -> u64` — rejected. Production publication must retain native size+digest evidence and verify the publication before bootstrap authority is returned. +10. Check `destination.exists()` and then rename the stage — rejected. On overwrite-capable rename semantics the sequence is racy. +11. Create the destination with `std::fs::hard_link(stage, destination)` and remove the private stage name — selected for the same-filesystem project root. It creates the destination without clobbering an existing name and keeps the synchronized bytes unchanged. +12. Add `contentSha256` to the existing analysis `LocalAudioSource` payload — rejected. Python admission is strict and this would mix persistence evidence with a narrower runtime request. +13. Define a separate path-free `LocalAudioPublicationIdentity` whose artifact name is derived from canonical native evidence — selected. This keeps Resource Admission as the copy/hash authority and gives #970 a serializable persistence input without absolute paths. ## Implementation and exact evidence -The hardening chain remains cumulative and test-first where behavior changed: +The cumulative hardening remains test-first where behavior changed: -- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` through an ordinary non-force ancestry update. -- RED `804a2867e877947feaffb1da6c6072e6a49049fe` and fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` established exact-limit acceptance and one-byte-over rejection in the bounded-copy port. -- `a2b1bd9e33a69be75f813f005abd37345200ce55` moved successful local-file intake to an app-owned same-project stage and published `source.` only after bounded copy; `323a7fac00c4954af12b382802a9d6f8359ef4c5` exported that core port to Tauri. -- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` and fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` separated source-read from destination-write failures and made the one-byte over-limit check read-only. -- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` and fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` introduced `LocalAudioCopyReceipt { file_size_bytes, content_sha256 }` and streaming SHA-256 over successfully written bytes only. -- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` and fix `d1ba40683772019577fec4d8c767ff8b23294e38` exposed reader-only `sha256_hex_reader` so dependent contexts can reuse one implementation without acquiring path authority. -- Publication RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` and fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` introduced `verify_local_audio_publication_receipt`, requiring exact byte-count and SHA-256 equality for an already-open published reader; `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exported it. -- Bounded-verification RED `6a0692ee288d3b126bd0598e07e03c88a702d567` and fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` changed publication verification to stop after expected bytes plus one growth probe instead of hashing an invalid replacement up to the global ceiling. -- Production-integration RED `ed9fe7eba6261753dc0f68e820e2b642703fe2cd` added a focused Tauri contract requiring the actual local-audio materializer to consume both `copy_bounded_local_audio_with_receipt` and `verify_local_audio_publication_receipt`, and forbidding the compatibility byte-count-only call on that function. -- Causal production fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` switched `materialize_local_audio_source` to the receipt API, calls `sync_all` on the same-project stage, publishes app-owned `source.`, rejects a published path observed as symlink/non-file, reopens the publication, checks descriptor length, requires `verify_local_audio_publication_receipt` equality, re-checks published path metadata, and returns the receipt's admitted byte count. -- No-clobber RED `45b1f72abeded4e478775d31085244621f68c9f0` requires production publication to use an atomic no-clobber destination create and explicitly forbids the `destination.exists()` plus overwrite-capable `rename` sequence. -- No-clobber fix `eb972e951ef090c92b595c752b18d66f11f6b96e` replaces check-then-rename with same-filesystem `hard_link(stage, destination)`, removes the private stage name only after the destination link exists, and fails closed if either publication or stage cleanup cannot complete. +- `dbeee9c7407c72f999f584eb0eb9342ddc39fddd` adopted protected `develop@314ddeae7b775a4957594b599358c8255617eb2e` through ordinary non-force ancestry. +- RED `804a2867e877947feaffb1da6c6072e6a49049fe` and fix `0beee45b98e51ba46b571a82c6d0d93db61ea8d6` established exact-limit acceptance and one-byte-over rejection. +- `a2b1bd9e33a69be75f813f005abd37345200ce55` moved successful local-file intake to an app-owned same-project stage; `323a7fac00c4954af12b382802a9d6f8359ef4c5` exported the core port to Tauri. +- Diagnostics RED `131d6d7220985abd207559e6eb5dc122ac989cf4` and fix `ac4adfdb5df82f48aadd5e028433e3336d3ce2ae` separated source-read and destination-write failures and made the one-byte over-limit check read-only. +- Content-identity RED `dc413794fb84c736085ab77b763854ba0f58bdf1` and fix `566cd1f991296e7f3c288cb07a11c2d2effb258a` introduced `LocalAudioCopyReceipt { file_size_bytes, content_sha256 }`. +- Shared-kernel RED `373824c7bbb40f2df1bb2721316680378c104834` and fix `d1ba40683772019577fec4d8c767ff8b23294e38` exposed reader-only `sha256_hex_reader`. +- Publication RED `fdfdd7003b8a9162f846dcf22ffe66a3afd5f47e` and fix `a1c85cbfbdc7051169f097e8ad235e3bbac439d3` introduced `verify_local_audio_publication_receipt`; `20e7faaddd619c6cbd053876ca6de27b9933a4a2` exported it. +- Bounded-verification RED `6a0692ee288d3b126bd0598e07e03c88a702d567` and fix `c65a9fd312f4d67e6d1cad83b80b1213e692c8dd` changed publication verification to stop after expected bytes plus one growth probe. +- Production-integration RED `ed9fe7eba6261753dc0f68e820e2b642703fe2cd` and fix `bdf8f87d5e5c9db423537c7633e7ff4b92bec5b6` moved the Tauri materializer onto native receipt + publication verification. +- No-clobber RED `45b1f72abeded4e478775d31085244621f68c9f0` and fix `eb972e951ef090c92b595c752b18d66f11f6b96e` replaced check-then-rename with same-filesystem hard-link publication. +- Path-free handoff RED `bad908c83bfb89f545f0f2f637d96ac8fdfa3e0e` requires exact camelCase serialization of the five persistence fields, no path fields, and fail-closed rejection of invalid native evidence. +- Path-free handoff fix `87bdeea92d3bb6dc45eb666f422bd8a3d36f3872` adds `LocalAudioPublicationIdentity` and `build_local_audio_publication_identity`; export `344a9a39f32ac40b3e137c76e2cfd46243827bb5` makes the contract available from `bandscope_desktop_core` to the #970 owner. -The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Reader tests cover short reads, `Interrupted`, and non-interrupted failure. These are correctness regressions, not validation-module evidence. +The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Those are correctness regressions, not validation-module evidence. ## Security Notes -The selected audio path, file metadata, and media bytes are untrusted. The OS file dialog supplies initial user authority; BandScope uses that path only to canonicalize and open the source. The project-owned artifact is the authority used after successful admission. - -The core hash and publication-verification ports accept no path and create no descriptor. Resource Admission or another owning context supplies an already-authorized reader. A staging receipt cannot be promoted when the published bytes do not reproduce both its length and digest. +The selected audio path, file metadata, and media bytes are untrusted. The OS file dialog supplies initial user authority; BandScope uses that path only to canonicalize and open the source. The project-owned artifact is the authority after successful admission. -The production Tauri caller now synchronizes the stage, creates the destination through a no-clobber same-filesystem hard link, removes the private stage name, requires regular/non-symlink observations of the published path, opens the published object, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Any publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; no source/destination path, raw OS error, or audio bytes are exposed. If hard-link creation is unavailable on the project filesystem, admission fails closed rather than silently downgrading to an overwrite-capable publication primitive. +The production Tauri materializer synchronizes the stage, creates the destination through a no-clobber same-filesystem hard link, removes the private stage name, requires regular/non-symlink path observations, opens the publication, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; source/destination paths, raw OS errors, and audio bytes are not exposed. -Those portable checks materially narrow name clobbering and linked-object substitution but are not an atomic no-follow open guarantee. A platform-specific descriptor acquisition design remains necessary if BandScope needs `O_NOFOLLOW`/reparse-point-equivalent race semantics against a same-user adversary. The parent directory is also not yet explicitly synchronized after destination-link creation and stage unlink, so power-loss durability of the directory entries is not claimed here. +`LocalAudioPublicationIdentity` does not acquire filesystem authority. It converts already verified native evidence into a deterministic, path-free value for the persistence boundary. Invalid project ids, extensions, byte counts, or digest encodings fail closed. The type is not evidence that the production Tauri command has already retained the identity; that final binding is still required before #970 can consume it as durable project truth. -No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt is non-secret content identity. It is not yet part of the current bootstrap/persistence wire contract. +No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt and publication identity are non-secret content identity. ## Test and acceptance points - exact 100 MiB encoded-byte limit accepted; one byte over rejected; - empty source rejected; -- source-reader failure keeps the selected-audio diagnosis; -- destination-writer failure keeps the project-workspace diagnosis; +- source-reader and destination-writer failures remain distinct and path-safe; - `Interrupted` reads retry without changing identity; - failed writes cannot return a partial receipt; -- the growth probe is neither staged nor included in the receipt digest; -- SHA-256 standard vectors and chunked/short-read paths agree; +- the growth probe is neither staged nor hashed; - unchanged published bytes reproduce the staging receipt; -- same-size content mutation, truncation, growth, or publication-read failure fails closed; -- invalid receipt lengths fail before reading publication bytes; -- grown publication is rejected after expected bytes plus one probe; -- production publication cannot use an existence-check plus overwrite-capable rename and must fail closed when the fixed destination name already exists; -- production Tauri local-file materialization must compile against and call the receipt and publication-verification ports, not the compatibility byte-count adapter; +- same-size mutation, truncation, growth, or publication-read failure fails closed; +- grown publication stops after expected bytes plus one probe; +- production publication cannot use existence-check plus overwrite-capable rename; +- production Tauri local-file materialization consumes receipt and publication-verification ports, not the compatibility byte-count adapter; +- path-free identity serializes exactly the five persistence fields and cannot serialize `path`/`sourcePath`; +- invalid project ids, uppercase/unsupported extensions, zero/oversized byte counts, and noncanonical SHA-256 encodings are rejected; - hosted Rust/Tauri, Windows, macOS, security, SBOM, coverage/package, and independent-review evidence must be reacquired on the final exact #866 head. -Synthetic arrays or source-text checks do not substitute for the later production scientific acceptance requirement. Rights-cleared real decoded audio still has to exercise the integrated Windows/macOS intake/decode/analysis/playback path where the relevant commercial claim is made. +Synthetic arrays or source-text checks do not substitute for production scientific acceptance. Rights-cleared real decoded audio still has to exercise the integrated Windows/macOS intake/decode/analysis/playback path where the relevant commercial claim is made. ## Remaining risks and follow-up -The local-file production path now binds bootstrap authority to a no-clobber app-owned publication whose bytes reproduce the native staging receipt. The next cross-context step is **not** another copy or hash implementation: #866 must expose a path-free publication-identity receipt suitable for #970 v3, while the analysis engine may continue to consume its narrower runtime `LocalAudioSource` path metadata. The current Rust/TypeScript/Python analysis `LocalAudioSource` contract does not include `contentSha256`, so injecting a new field into that runtime request without a versioned contract change would break strict Python admission. A distinct bootstrap/persistence source-identity boundary is therefore preferred. +The local-file path now has two separate native contracts: `LocalAudioCopyReceipt` proves the exact bytes staged/published, and `LocalAudioPublicationIdentity` represents the path-free durable evidence intended for Project Persistence. The remaining #866 production step is to construct that identity from the already verified receipt in the Tauri local-file materializer and retain it in a native persistence handoff keyed by the minted project id. It must not be reconstructed from renderer JSON. -After Project Persistence receives `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. +After #970 consumes `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. -When #866 enters the #1160 ancestry, the private playable-stem SHA-256 implementation should be deleted in favor of `bandscope_desktop_core::sha256_hex_reader` while preserving its stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Platform-atomic no-follow acquisition and parent-directory crash durability remain Resource Admission/platform work. Issue #1129 remains the commercial decoder-dependency gate. +When #866 enters #1160 ancestry, the private playable-stem SHA-256 implementation should be deleted in favor of `bandscope_desktop_core::sha256_hex_reader` while preserving stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Platform-atomic no-follow acquisition and parent-directory crash durability remain Resource Admission/platform work. Issue #1129 remains the commercial decoder-dependency gate. ## References From 645457e7798f56d394ed7ef2346737227d9e1b80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:10:13 +0900 Subject: [PATCH 310/448] test(audio): require native publication identity retention --- .../tests/local_audio_publication_contract.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index d23fcdf6c..2f6d5dbfe 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -49,3 +49,39 @@ fn local_audio_publication_must_not_overwrite_an_existing_source_name() { "overwrite-capable rename must not publish the immutable project source" ); } + +#[test] +fn verified_local_audio_publication_is_retained_as_path_free_native_identity() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("build_local_audio_publication_identity(project_id, &extension, &receipt)"), + "the production materializer must derive persistence evidence from the exact verified native receipt" + ); + assert!( + materializer.contains("LocalAudioPublicationIdentity"), + "the production materializer must return the typed path-free identity beside runtime source authority" + ); + + let selector_start = source + .find("fn select_local_audio_source(") + .expect("local-audio selector command must remain present"); + let selector_tail = &source[selector_start..]; + let selector_end = selector_tail + .find("\n}\n\n#[tauri::command]\nasync fn import_youtube_url") + .expect("local-audio selector boundary must remain inspectable"); + let selector = &selector_tail[..selector_end]; + + assert!( + selector.contains("store_publication_identity(&state, publication_identity)"), + "verified path-free identity must be retained natively for Project Persistence rather than reconstructed from renderer JSON" + ); +} From f89996b9b1964673f6f976c27707a836755b653e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:10:52 +0900 Subject: [PATCH 311/448] test(audio): keep Tauri retention as next production slice --- .../tests/local_audio_publication_contract.rs | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index 2f6d5dbfe..d23fcdf6c 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -49,39 +49,3 @@ fn local_audio_publication_must_not_overwrite_an_existing_source_name() { "overwrite-capable rename must not publish the immutable project source" ); } - -#[test] -fn verified_local_audio_publication_is_retained_as_path_free_native_identity() { - let source = include_str!("../src/main.rs"); - let materializer_start = source - .find("fn materialize_local_audio_source(") - .expect("desktop materializer must remain present"); - let materializer_tail = &source[materializer_start..]; - let materializer_end = materializer_tail - .find("\n}\n\nfn parse_request_payload") - .expect("materializer boundary must remain inspectable"); - let materializer = &materializer_tail[..materializer_end]; - - assert!( - materializer.contains("build_local_audio_publication_identity(project_id, &extension, &receipt)"), - "the production materializer must derive persistence evidence from the exact verified native receipt" - ); - assert!( - materializer.contains("LocalAudioPublicationIdentity"), - "the production materializer must return the typed path-free identity beside runtime source authority" - ); - - let selector_start = source - .find("fn select_local_audio_source(") - .expect("local-audio selector command must remain present"); - let selector_tail = &source[selector_start..]; - let selector_end = selector_tail - .find("\n}\n\n#[tauri::command]\nasync fn import_youtube_url") - .expect("local-audio selector boundary must remain inspectable"); - let selector = &selector_tail[..selector_end]; - - assert!( - selector.contains("store_publication_identity(&state, publication_identity)"), - "verified path-free identity must be retained natively for Project Persistence rather than reconstructed from renderer JSON" - ); -} From 03f14fc4ef2c67f0fcfcf841713f320faa058e1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:11:55 +0900 Subject: [PATCH 312/448] test(project): reject oversized source references --- .../core/tests/project_format_v3_source_reference.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/core/tests/project_format_v3_source_reference.rs b/apps/desktop/core/tests/project_format_v3_source_reference.rs index 5a241b123..0fad0a6d4 100644 --- a/apps/desktop/core/tests/project_format_v3_source_reference.rs +++ b/apps/desktop/core/tests/project_format_v3_source_reference.rs @@ -105,6 +105,13 @@ fn current_project_rejects_paths_and_untrusted_source_reference_shapes() { "fileSizeBytes": 0, "contentSha256": CONTENT_SHA256 }), + json!({ + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 100 * 1024 * 1024 + 1, + "contentSha256": CONTENT_SHA256 + }), json!({ "projectId": "project-400-4", "artifactName": "source.wav", From a74d05d07a1e1e811c8b37906ffa9f3415ef62a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:14:51 +0900 Subject: [PATCH 313/448] test(project): bound renderer source identity bytes --- .../src/lib/projectDocument.plainRecord.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts index 3feebbd03..cff1157db 100644 --- a/apps/desktop/src/lib/projectDocument.plainRecord.test.ts +++ b/apps/desktop/src/lib/projectDocument.plainRecord.test.ts @@ -3,6 +3,7 @@ import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { parseProjectDocument } from "./projectDocument"; const CONTENT_SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; class ProjectDocumentWithPrototype { song = createDemoRehearsalSong(); @@ -142,6 +143,22 @@ describe("project document plain-record admission", () => { expect(getterCalls).toBe(0); }); + it("rejects a source reference whose claimed bytes exceed the Resource Admission ceiling", () => { + expect(() => + parseProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1, + contentSha256: CONTENT_SHA256 + } + }) + ).toThrow("Invalid project document"); + }); + it("fails closed when optional source-reference descriptor inspection throws", () => { const document = new Proxy( { From c956044891cb04710732d50679dfef4bfe7c5a13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:18:44 +0900 Subject: [PATCH 314/448] fix(project): bound durable source identity bytes --- apps/desktop/core/src/project_format.rs | 26 +++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index 011bc99db..de9e65bbc 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -16,6 +16,14 @@ use serde_json::Value; /// Current on-disk project format version, independent of the app version. pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 3; +/// Maximum admitted full-mix bytes a durable source reference may claim. +/// +/// This mirrors the current Resource Admission ceiling so an untrusted project +/// document cannot turn an impossible source identity into restart authority. +/// Once the #866 foundation enters this branch's ancestry, consume its exported +/// canonical constant instead of maintaining two declarations. +const MAX_PROJECT_SOURCE_REFERENCE_BYTES: u64 = 100 * 1024 * 1024; + /// Stable playback-source identity stored in project preferences. /// /// These values describe rehearsal intent. They are resolved against current @@ -121,6 +129,7 @@ fn sha256_hex_is_canonical(value: &str) -> bool { fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool { if !is_valid_project_id(&reference.project_id) || reference.file_size_bytes == 0 + || reference.file_size_bytes > MAX_PROJECT_SOURCE_REFERENCE_BYTES || !AUDIO_EXTENSIONS.contains(&reference.extension.as_str()) || !sha256_hex_is_canonical(&reference.content_sha256) { @@ -148,9 +157,9 @@ fn validate_document(document: ProjectDocumentPayload) -> Result Result { let document = serde_json::from_value::(value) .map_err(|_| "Invalid project document payload".to_string())?; @@ -163,11 +172,12 @@ pub fn project_document_from_value(value: Value) -> Result Result { let root = serde_json::from_str::(content) .map_err(|_| "Invalid project file format".to_string())?; From 83127b55d121deff612160ed014e7a83abaf06c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:21:46 +0900 Subject: [PATCH 315/448] fix(project): align renderer source identity ceiling --- apps/desktop/src/lib/projectDocument.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/projectDocument.ts b/apps/desktop/src/lib/projectDocument.ts index 6e9421d1a..89d90e666 100644 --- a/apps/desktop/src/lib/projectDocument.ts +++ b/apps/desktop/src/lib/projectDocument.ts @@ -39,6 +39,7 @@ const PROJECT_SOURCE_EXTENSIONS = new Set([ ]); const PROJECT_ID_PATTERN = /^project-\d+-\d+$/; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; +const MAX_PROJECT_SOURCE_REFERENCE_BYTES = 100 * 1024 * 1024; type OwnDataProperty = | { ok: true; value: unknown } @@ -160,6 +161,7 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { typeof fileSizeBytes !== "number" || !Number.isSafeInteger(fileSizeBytes) || fileSizeBytes <= 0 || + fileSizeBytes > MAX_PROJECT_SOURCE_REFERENCE_BYTES || typeof contentSha256 !== "string" || !SHA256_HEX_PATTERN.test(contentSha256) ) { @@ -178,7 +180,8 @@ function parseProjectSourceReference(value: unknown): ProjectSourceReference { /** * Validate the renderer-visible project document without accepting filesystem paths, * runtime capability URLs, generation tokens, prototype-bearing records, accessors, - * trapped record enumeration, ambiguous source digests, or unknown preference/source-reference fields. + * trapped record enumeration, impossible source byte claims, ambiguous source digests, + * or unknown preference/source-reference fields. */ export function parseProjectDocument(value: unknown): ProjectDocument { if ( From cbfa967b16e94f2d84940665ce38537075a8ce41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:28:36 +0900 Subject: [PATCH 316/448] test(audio): require native retention of publication identity --- .../tests/local_audio_publication_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index d23fcdf6c..adca2d9a5 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -49,3 +49,47 @@ fn local_audio_publication_must_not_overwrite_an_existing_source_name() { "overwrite-capable rename must not publish the immutable project source" ); } + +#[test] +fn local_audio_selection_retains_verified_path_free_identity_in_native_state() { + let source = include_str!("../src/main.rs"); + let materializer_start = source + .find("fn materialize_local_audio_source(") + .expect("desktop materializer must remain present"); + let materializer_tail = &source[materializer_start..]; + let materializer_end = materializer_tail + .find("\n}\n\nfn parse_request_payload") + .expect("materializer boundary must remain inspectable"); + let materializer = &materializer_tail[..materializer_end]; + + assert!( + materializer.contains("build_local_audio_publication_identity"), + "the verified publication receipt must be converted to the canonical path-free native identity" + ); + assert!( + materializer.contains("LocalAudioPublicationIdentity"), + "materialization must return typed native publication evidence alongside analysis bootstrap state" + ); + + let selector_start = source + .find("fn select_local_audio_source(") + .expect("desktop local-audio selector must remain present"); + let selector_tail = &source[selector_start..]; + let selector_end = selector_tail + .find("\n}\n\n#[tauri::command]\nasync fn import_youtube_url") + .expect("selector boundary must remain inspectable"); + let selector = &selector_tail[..selector_end]; + + assert!( + source.contains("struct LocalAudioPublicationIdentityState"), + "Tauri must retain verified source identity in native state instead of renderer JSON" + ); + assert!( + selector.contains("store_local_audio_publication_identity"), + "local selection must retain the verified identity before returning bootstrap authority" + ); + assert!( + source.contains(".manage(LocalAudioPublicationIdentityState::default())"), + "the native identity state must be registered with the Tauri runtime" + ); +} From d8c57ce1d64d0bc9963219740aeaa83d9569a90b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:29:50 +0900 Subject: [PATCH 317/448] test(audio): keep native-retention RED off canonical head --- .../tests/local_audio_publication_contract.rs | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index adca2d9a5..d23fcdf6c 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -49,47 +49,3 @@ fn local_audio_publication_must_not_overwrite_an_existing_source_name() { "overwrite-capable rename must not publish the immutable project source" ); } - -#[test] -fn local_audio_selection_retains_verified_path_free_identity_in_native_state() { - let source = include_str!("../src/main.rs"); - let materializer_start = source - .find("fn materialize_local_audio_source(") - .expect("desktop materializer must remain present"); - let materializer_tail = &source[materializer_start..]; - let materializer_end = materializer_tail - .find("\n}\n\nfn parse_request_payload") - .expect("materializer boundary must remain inspectable"); - let materializer = &materializer_tail[..materializer_end]; - - assert!( - materializer.contains("build_local_audio_publication_identity"), - "the verified publication receipt must be converted to the canonical path-free native identity" - ); - assert!( - materializer.contains("LocalAudioPublicationIdentity"), - "materialization must return typed native publication evidence alongside analysis bootstrap state" - ); - - let selector_start = source - .find("fn select_local_audio_source(") - .expect("desktop local-audio selector must remain present"); - let selector_tail = &source[selector_start..]; - let selector_end = selector_tail - .find("\n}\n\n#[tauri::command]\nasync fn import_youtube_url") - .expect("selector boundary must remain inspectable"); - let selector = &selector_tail[..selector_end]; - - assert!( - source.contains("struct LocalAudioPublicationIdentityState"), - "Tauri must retain verified source identity in native state instead of renderer JSON" - ); - assert!( - selector.contains("store_local_audio_publication_identity"), - "local selection must retain the verified identity before returning bootstrap authority" - ); - assert!( - source.contains(".manage(LocalAudioPublicationIdentityState::default())"), - "the native identity state must be registered with the Tauri runtime" - ); -} From 106ae75cad85553e56964a9844ea7a01f6ce456c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:45:01 +0900 Subject: [PATCH 318/448] test(audio): require native retention of publication identity --- .../tests/local_audio_publication_contract.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index d23fcdf6c..f818d0e0b 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -49,3 +49,25 @@ fn local_audio_publication_must_not_overwrite_an_existing_source_name() { "overwrite-capable rename must not publish the immutable project source" ); } + +#[test] +fn local_audio_selection_retains_verified_path_free_identity_in_native_state() { + let source = include_str!("../src/main.rs"); + + assert!( + source.contains("struct LocalAudioPublicationIdentityState"), + "verified source identity must have a native-only state owner" + ); + assert!( + source.contains("build_local_audio_publication_identity(project_id, &extension, &receipt)"), + "the production materializer must derive persistence identity from the verified native receipt" + ); + assert!( + source.contains("store_local_audio_publication_identity(&publication_state, publication_identity)"), + "selection must retain native publication identity before returning bootstrap authority" + ); + assert!( + source.contains(".manage(LocalAudioPublicationIdentityState::default())"), + "the native publication identity state must be registered with the Tauri runtime" + ); +} From e4e2ba734bc80304a754ce2eb52e473fd9ee3631 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:46:37 +0900 Subject: [PATCH 319/448] fix(audio): retain verified publication identity natively --- apps/desktop/src-tauri/src/main.rs | 50 +++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index d614f79e8..92e0cf784 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -14,6 +14,16 @@ use std::{ use tauri::{Emitter, Manager, Runtime}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +/// Native-only cache of verified local-audio publication identities. +/// +/// Security Notes: entries are keyed only by BandScope-minted project ids and +/// contain the bounded path-free publication evidence emitted by Resource +/// Admission. User filesystem paths are never retained in this state. +#[derive(Default)] +struct LocalAudioPublicationIdentityState( + std::sync::Mutex>, +); + fn iso_timestamp_now() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -158,7 +168,8 @@ fn app_owned_root( fn materialize_local_audio_source( path: &Path, project_root: &Path, -) -> Result { + project_id: &str, +) -> Result<(LocalAudioSourcePayload, LocalAudioPublicationIdentity), String> { let canonical = path .canonicalize() .map_err(|_| "Could not read the selected audio file.".to_string())?; @@ -262,12 +273,17 @@ fn materialize_local_audio_source( return Err("Could not prepare the local project workspace.".to_string()); } - Ok(LocalAudioSourcePayload { - source_path: destination.to_string_lossy().into_owned(), - file_name, - extension, - file_size_bytes: receipt.file_size_bytes, - }) + let publication_identity = + build_local_audio_publication_identity(project_id, &extension, &receipt)?; + Ok(( + LocalAudioSourcePayload { + source_path: destination.to_string_lossy().into_owned(), + file_name, + extension, + file_size_bytes: receipt.file_size_bytes, + }, + publication_identity, + )) } fn parse_request_payload(payload: Value) -> Result { @@ -403,6 +419,20 @@ fn store_bootstrap_source(state: &AppState, summary: ProjectBootstrapSummaryPayl } } +/// Retain path-free publication evidence before the renderer receives bootstrap authority. +fn store_local_audio_publication_identity( + state: &LocalAudioPublicationIdentityState, + identity: LocalAudioPublicationIdentity, +) -> Result<(), String> { + let project_id = identity.project_id.clone(); + let mut identities = state + .0 + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + identities.insert(project_id, identity); + Ok(()) +} + fn lookup_bootstrap_source( state: &AppState, project_id: &str, @@ -736,6 +766,7 @@ fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) -> fn select_local_audio_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, ) -> Result { let path = FileDialog::new() .add_filter("Audio", &AUDIO_EXTENSIONS) @@ -745,7 +776,9 @@ fn select_local_audio_source( let project_root = app_owned_root(&app, "projects", &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; let temp_root = app_owned_root(&app, "temp", &project_id)?; - let source = materialize_local_audio_source(&path, &project_root)?; + let (source, publication_identity) = + materialize_local_audio_source(&path, &project_root, &project_id)?; + store_local_audio_publication_identity(&publication_state, publication_identity)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -970,6 +1003,7 @@ fn remove_score_pdf( fn main() { tauri::Builder::default() .manage(AppState::default()) + .manage(LocalAudioPublicationIdentityState::default()) .invoke_handler(tauri::generate_handler![ select_local_audio_source, import_youtube_url, From 9a13d2bb91c05039481bc9eaef552f2222fcad2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:48:37 +0900 Subject: [PATCH 320/448] docs(audio): record native publication identity retention --- .../local-audio-source-materialization.md | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/local-audio-source-materialization.md b/docs/doctoring/local-audio-source-materialization.md index d0dac8f89..9e9fa7849 100644 --- a/docs/doctoring/local-audio-source-materialization.md +++ b/docs/doctoring/local-audio-source-materialization.md @@ -16,11 +16,12 @@ The hardening sequence exposed distinct defects: - publication verification initially read against the product-wide 100 MiB ceiling instead of the receipt's tighter expected length; - the production Tauri materializer initially discarded the receipt and stayed on the byte-count-only adapter; - publication initially used `destination.exists()` followed by overwrite-capable `rename`, creating a check-then-act clobber window; -- even after publication verification existed, Project Persistence still had no typed path-free handoff value for `projectId + artifactName + extension + fileSizeBytes + contentSha256`. +- even after publication verification existed, Project Persistence still had no typed path-free handoff value for `projectId + artifactName + extension + fileSizeBytes + contentSha256`; +- after that type existed, the production selector still discarded the verified identity instead of retaining it in native state for the persistence owner. -The canonical #866 branch now repairs those defects through the path-free identity type. Production local-file materialization already consumes the native receipt, synchronizes the stage, publishes with a same-filesystem no-clobber hard link, removes the private stage name, and verifies the published bytes before returning bootstrap authority. `LocalAudioPublicationIdentity` now supplies the separate durable evidence shape for Project Persistence without changing the strict analysis-runtime `LocalAudioSource` wire. +The canonical #866 branch now repairs those defects through native retention. Production local-file materialization consumes the native receipt, synchronizes the stage, publishes with a same-filesystem no-clobber hard link, removes the private stage name, verifies the published bytes, derives `LocalAudioPublicationIdentity` from that verified receipt, and retains the path-free value in native Tauri state keyed by the locally minted project id before returning bootstrap authority. The strict analysis-runtime `LocalAudioSource` wire remains unchanged. -The remaining production integration is narrower: the Tauri local-file command still has to construct and retain the new path-free identity from its already verified receipt, then expose it only through the native persistence boundary consumed by #970. Restart re-admission, platform-atomic no-follow descriptor acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. +The remaining integration is now across the owning persistence boundary rather than the intake copy/hash path: #970 must consume this native identity when constructing durable `sourceReference`, and restart must re-admit the app-owned artifact before fresh playback authority is minted. Platform-atomic no-follow descriptor acquisition, parent-directory crash durability, YouTube durable-source policy, and decoder licensing remain separate open work. ## Constraints and invariants @@ -38,6 +39,8 @@ The remaining production integration is narrower: the Tauri local-file command s - The analysis-runtime `LocalAudioSource` contract remains `sourcePath + fileName + extension + fileSizeBytes`. `contentSha256` is not injected into that strict Rust/TypeScript/Python request without a versioned contract change. - The persistence identity is a distinct contract. It contains exactly `projectId + artifactName + extension + fileSizeBytes + contentSha256`; it contains no `path` or `sourcePath` field. - The persistence identity accepts only an existing BandScope project-id grammar, canonical lowercase admitted extension, byte size `1..=100 MiB`, and exactly 64 lowercase hexadecimal SHA-256 characters. `artifactName` is derived as `source.` rather than accepted from renderer input. +- Verified persistence identity is retained only in native Tauri state keyed by the minted project id. The renderer does not author or supply that evidence. +- If native identity state cannot be retained, local-source selection fails closed rather than returning bootstrap authority without persistence evidence. - Portable `symlink_metadata` / open / re-check logic narrows linked-object substitution but does not claim atomic `O_NOFOLLOW` or Windows reparse-point-equivalent semantics. - The parent project directory is not yet explicitly synchronized after destination-link creation and stage unlink, so power-loss durability of the directory entries is not claimed. @@ -56,6 +59,7 @@ The remaining production integration is narrower: the Tauri local-file command s 11. Create the destination with `std::fs::hard_link(stage, destination)` and remove the private stage name — selected for the same-filesystem project root. It creates the destination without clobbering an existing name and keeps the synchronized bytes unchanged. 12. Add `contentSha256` to the existing analysis `LocalAudioSource` payload — rejected. Python admission is strict and this would mix persistence evidence with a narrower runtime request. 13. Define a separate path-free `LocalAudioPublicationIdentity` whose artifact name is derived from canonical native evidence — selected. This keeps Resource Admission as the copy/hash authority and gives #970 a serializable persistence input without absolute paths. +14. Return bootstrap authority while leaving the verified identity only in a local stack variable — rejected. The selector now retains the typed identity in native Tauri state keyed by project id before returning; #970 can adopt that native evidence without trusting renderer-authored digest/path data. ## Implementation and exact evidence @@ -73,6 +77,9 @@ The cumulative hardening remains test-first where behavior changed: - No-clobber RED `45b1f72abeded4e478775d31085244621f68c9f0` and fix `eb972e951ef090c92b595c752b18d66f11f6b96e` replaced check-then-rename with same-filesystem hard-link publication. - Path-free handoff RED `bad908c83bfb89f545f0f2f637d96ac8fdfa3e0e` requires exact camelCase serialization of the five persistence fields, no path fields, and fail-closed rejection of invalid native evidence. - Path-free handoff fix `87bdeea92d3bb6dc45eb666f422bd8a3d36f3872` adds `LocalAudioPublicationIdentity` and `build_local_audio_publication_identity`; export `344a9a39f32ac40b3e137c76e2cfd46243827bb5` makes the contract available from `bandscope_desktop_core` to the #970 owner. +- An earlier exploratory retention RED `cbfa967b16e94f2d84940665ce38537075a8ce41` was intentionally neutralized by `d8c57ce1d64d0bc9963219740aeaa83d9569a90b` rather than leaving a known failing head; those two commits add no production claim. +- Production native-retention RED `106ae75cad85553e56964a9844ea7a01f6ce456c` requires the materializer to derive the typed identity from the verified receipt, the selector to store it in native state, and Tauri to register that state. +- Native-retention fix `e4e2ba734bc80304a754ce2eb52e473fd9ee3631` returns `LocalAudioSourcePayload + LocalAudioPublicationIdentity` from materialization, stores the identity in `LocalAudioPublicationIdentityState` before bootstrap authority is returned, and registers the native state with the Tauri runtime. The SHA-256 implementation is checked against standard known-answer vectors including the empty message, `abc`, the multi-block vector, and one million `a` bytes. Those are correctness regressions, not validation-module evidence. @@ -82,7 +89,7 @@ The selected audio path, file metadata, and media bytes are untrusted. The OS fi The production Tauri materializer synchronizes the stage, creates the destination through a no-clobber same-filesystem hard link, removes the private stage name, requires regular/non-symlink path observations, opens the publication, checks descriptor size, verifies exact receipt equality, and performs a post-verification path check. Publication mismatch or read failure is normalized to the bounded project-workspace diagnosis; source/destination paths, raw OS errors, and audio bytes are not exposed. -`LocalAudioPublicationIdentity` does not acquire filesystem authority. It converts already verified native evidence into a deterministic, path-free value for the persistence boundary. Invalid project ids, extensions, byte counts, or digest encodings fail closed. The type is not evidence that the production Tauri command has already retained the identity; that final binding is still required before #970 can consume it as durable project truth. +`LocalAudioPublicationIdentity` does not acquire filesystem authority. It converts already verified native evidence into a deterministic, path-free value for the persistence boundary. Invalid project ids, extensions, byte counts, or digest encodings fail closed. Production local-file selection now retains that value in native Tauri state before returning the ordinary bootstrap summary, so the renderer does not need to invent a digest or persist a host path. Durable project serialization and restart re-admission remain #970 responsibilities. No new logging, telemetry, network transfer, or raw-media export is introduced. The SHA-256 receipt and publication identity are non-secret content identity. @@ -101,15 +108,16 @@ No new logging, telemetry, network transfer, or raw-media export is introduced. - production Tauri local-file materialization consumes receipt and publication-verification ports, not the compatibility byte-count adapter; - path-free identity serializes exactly the five persistence fields and cannot serialize `path`/`sourcePath`; - invalid project ids, uppercase/unsupported extensions, zero/oversized byte counts, and noncanonical SHA-256 encodings are rejected; +- production local-file selection derives identity from the verified receipt and retains it in registered native Tauri state before returning bootstrap authority; - hosted Rust/Tauri, Windows, macOS, security, SBOM, coverage/package, and independent-review evidence must be reacquired on the final exact #866 head. Synthetic arrays or source-text checks do not substitute for production scientific acceptance. Rights-cleared real decoded audio still has to exercise the integrated Windows/macOS intake/decode/analysis/playback path where the relevant commercial claim is made. ## Remaining risks and follow-up -The local-file path now has two separate native contracts: `LocalAudioCopyReceipt` proves the exact bytes staged/published, and `LocalAudioPublicationIdentity` represents the path-free durable evidence intended for Project Persistence. The remaining #866 production step is to construct that identity from the already verified receipt in the Tauri local-file materializer and retain it in a native persistence handoff keyed by the minted project id. It must not be reconstructed from renderer JSON. +The local-file path now has two separate native contracts: `LocalAudioCopyReceipt` proves the exact bytes staged/published, and retained `LocalAudioPublicationIdentity` represents the path-free durable evidence intended for Project Persistence. The next cross-owner step is for #970 to consume that retained identity when writing `sourceReference`; it must not reconstruct digest/path evidence from renderer JSON or re-hash the user's original media. -After #970 consumes `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. +After #970 persists `projectId + artifactName + extension + fileSizeBytes + contentSha256`, restart must resolve only the app-owned artifact, re-establish regular/no-link containment, bounded size/SHA-256 and applicable decode admission, reconstruct a fresh bootstrap, and only then let #1160 combine persisted `selectedPlaybackSource` intent with fresh native stem availability. Missing preferred stems fail closed to Full mix. When #866 enters #1160 ancestry, the private playable-stem SHA-256 implementation should be deleted in favor of `bandscope_desktop_core::sha256_hex_reader` while preserving stem identity/error tests. YouTube intake still uses its owned cache artifact and needs an explicit durable-source promotion decision. Platform-atomic no-follow acquisition and parent-directory crash durability remain Resource Admission/platform work. Issue #1129 remains the commercial decoder-dependency gate. From 06092bec878f0e59fe51cabc040a1b1c6c082fa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:31:41 +0900 Subject: [PATCH 321/448] fix(ci): format audio decode regressions --- .../tests/test_audio_decode_port.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py index e1c4682a6..5035f27c6 100644 --- a/services/analysis-engine/tests/test_audio_decode_port.py +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -1,6 +1,7 @@ """Contract tests for the canonical local-audio decode port. -These regressions keep resource admission, decoder failure redaction, and decoded-output validation behind one owned boundary. +These regressions keep resource admission, decoder failure redaction, and + decoded-output validation behind one owned boundary. """ from __future__ import annotations @@ -23,7 +24,8 @@ def test_decode_mono_audio_preflights_then_validates_one_owned_decode( ) -> None: """Keep preflight, one decode, and decoded validation in strict order. - The decode port must own the sequence so downstream analyzers cannot bypass or duplicate resource admission. + The decode port must own the sequence so downstream analyzers cannot + bypass or duplicate resource admission. """ source = io.BytesIO(b"container") calls: list[tuple[str, object]] = [] @@ -98,7 +100,8 @@ def test_decode_mono_audio_redacts_third_party_decoder_failure( ) -> None: """Map third-party decoder details to a payload-safe policy error. - Native paths or token-shaped details may remain only in the exception cause for local debugging, never in buyer-facing error text. + Native paths or token-shaped details may remain only in the exception + cause for local debugging, never in buyer-facing error text. """ secret_detail = "/Users/alice/Music/private.m4a token=secret" monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) @@ -121,7 +124,8 @@ def test_decode_mono_audio_redacts_malformed_decoder_output( ) -> None: """Reject decoder output that cannot be normalized into bounded PCM. - Malformed third-party values must fail at the decode boundary rather than escaping into MIR analyzers. + Malformed third-party values must fail at the decode boundary rather than + escaping into MIR analyzers. """ monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) monkeypatch.setattr( @@ -141,7 +145,8 @@ def test_decode_mono_audio_preserves_decoded_policy_rejection( ) -> None: """Preserve rejection identity from decoded-audio resource validation. - The decode port must not collapse a precise post-decode budget failure into a generic malformed-container error. + The decode port must not collapse a precise post-decode budget failure + into a generic malformed-container error. """ rejection = AudioResourcePolicyError("decoded_sample_count_exceeded") monkeypatch.setattr(audio_decode, "preflight_audio_metadata", lambda *_args: None) From 839f5a0c29d468c41fc734ea3311ff0851fddbf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:31:54 +0900 Subject: [PATCH 322/448] fix(ci): format audio metadata regression --- services/analysis-engine/tests/test_audio_metadata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_metadata.py b/services/analysis-engine/tests/test_audio_metadata.py index 084913864..78e442619 100644 --- a/services/analysis-engine/tests/test_audio_metadata.py +++ b/services/analysis-engine/tests/test_audio_metadata.py @@ -57,7 +57,10 @@ def test_preflight_rejects_untrusted_source_metadata( assert error.value.reason == reason -@pytest.mark.parametrize("dependency_error", [RuntimeError("decoder detail"), ValueError("decoder detail")]) +@pytest.mark.parametrize( + "dependency_error", + [RuntimeError("decoder detail"), ValueError("decoder detail")], +) def test_preflight_maps_parser_failures_to_payload_free_policy_error( dependency_error: Exception, ) -> None: From 841e1c9b7329dba6d0ff16daecc009a2c3face0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:32:23 +0900 Subject: [PATCH 323/448] chore(test): normalize decode docstring wrap --- services/analysis-engine/tests/test_audio_decode_port.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_audio_decode_port.py b/services/analysis-engine/tests/test_audio_decode_port.py index 5035f27c6..6838584d9 100644 --- a/services/analysis-engine/tests/test_audio_decode_port.py +++ b/services/analysis-engine/tests/test_audio_decode_port.py @@ -1,7 +1,7 @@ """Contract tests for the canonical local-audio decode port. These regressions keep resource admission, decoder failure redaction, and - decoded-output validation behind one owned boundary. +decoded-output validation behind one owned boundary. """ from __future__ import annotations From 37ea9a5a68a7c140d13689c552e61849ebe5a6c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:02:32 +0900 Subject: [PATCH 324/448] test(project): reject renderer-authored source identity --- ...ect_format_v3_renderer_source_authority.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs diff --git a/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs b/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs new file mode 100644 index 000000000..b77a9cfe3 --- /dev/null +++ b/apps/desktop/core/tests/project_format_v3_renderer_source_authority.rs @@ -0,0 +1,31 @@ +use bandscope_desktop_core::project_document_from_value; +use serde_json::{json, Value}; + +const CONTENT_SHA256: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn v2_song() -> Value { + let fixture: Value = serde_json::from_str(include_str!("../testdata/project-v2.json")) + .expect("the checked-in v2 fixture should remain valid JSON"); + fixture["song"].clone() +} + +#[test] +fn renderer_cannot_author_source_reference_before_native_handoff() { + let payload = json!({ + "song": v2_song(), + "preferences": { "selectedPlaybackSource": "full_mix" }, + "sourceReference": { + "projectId": "project-400-4", + "artifactName": "source.wav", + "extension": "wav", + "fileSizeBytes": 4096, + "contentSha256": CONTENT_SHA256 + } + }); + + let error = project_document_from_value(payload) + .expect_err("renderer JSON must not author native filesystem identity or digest evidence"); + + assert_eq!(error, "Invalid project document payload"); +} From 01c09cf96b3e3eb71e57ec1f2193283af76a4149 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:03:03 +0900 Subject: [PATCH 325/448] fix(project): reject renderer-authored source identity --- apps/desktop/core/src/project_format.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index de9e65bbc..76749742e 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -154,15 +154,19 @@ fn validate_document(document: ProjectDocumentPayload) -> Result Result { let document = serde_json::from_value::(value) .map_err(|_| "Invalid project document payload".to_string())?; + if document.source_reference.is_some() { + return Err("Invalid project document payload".to_string()); + } validate_document(document) } From 4ea8c2a69088682da581c8ca125626d077f0044a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:05:16 +0900 Subject: [PATCH 326/448] test(project): block renderer source identity before IPC --- .../lib/projectDocumentSaveAuthority.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts diff --git a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts new file mode 100644 index 000000000..0898766ae --- /dev/null +++ b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { saveProjectDocument } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; +const CONTENT_SHA256 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("project document save authority", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("rejects renderer-authored source identity before persistence IPC", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + + await expect( + saveProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" }, + sourceReference: { + projectId: "project-400-4", + artifactName: "source.wav", + extension: "wav", + fileSizeBytes: 4096, + contentSha256: CONTENT_SHA256 + } + }) + ).rejects.toThrow("Invalid project document"); + + expect(invoke).not.toHaveBeenCalled(); + }); +}); From 0268568eb56e33f5dafb26c98b10a61eab9e86b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:05:53 +0900 Subject: [PATCH 327/448] fix(project): block renderer source identity before IPC --- apps/desktop/src/lib/analysis.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 2fc050f88..dd3b01567 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -355,9 +355,18 @@ export async function importYoutubeUrl(url: string): Promise { const parsedDocument = parseProjectDocument(projectDocument); + if (parsedDocument.sourceReference) { + throw new Error("Invalid project document"); + } await invokeAnalysis("save_project", { payload: parsedDocument }); } From a747cd6c164fce22ad41affaaef0e9f9da016a8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:06:13 +0900 Subject: [PATCH 328/448] test(project): align bridge with native source authority --- .../src/lib/projectDocumentBridge.test.ts | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/lib/projectDocumentBridge.test.ts b/apps/desktop/src/lib/projectDocumentBridge.test.ts index 56e36482f..46b7a69b2 100644 --- a/apps/desktop/src/lib/projectDocumentBridge.test.ts +++ b/apps/desktop/src/lib/projectDocumentBridge.test.ts @@ -48,25 +48,13 @@ describe("project document bridge", () => { } ); - it("persists only an app-owned source reference with content identity and never a user filesystem path", async () => { + it("rejects renderer-authored app-owned source evidence before persistence IPC", async () => { const invoke = vi.fn().mockResolvedValue(undefined); tauriWindow.__TAURI_INVOKE__ = invoke; const song = createDemoRehearsalSong(); - await saveProjectDocument({ - song, - preferences: { selectedPlaybackSource: "vocals" }, - sourceReference: { - projectId: "project-400-4", - artifactName: "source.wav", - extension: "wav", - fileSizeBytes: 4096, - contentSha256: CONTENT_SHA256 - } - }); - - expect(invoke).toHaveBeenCalledWith("save_project", { - payload: { + await expect( + saveProjectDocument({ song, preferences: { selectedPlaybackSource: "vocals" }, sourceReference: { @@ -76,9 +64,10 @@ describe("project document bridge", () => { fileSizeBytes: 4096, contentSha256: CONTENT_SHA256 } - } - }); - expect(JSON.stringify(invoke.mock.calls)).not.toContain("sourcePath"); + }) + ).rejects.toThrow("Invalid project document"); + + expect(invoke).not.toHaveBeenCalled(); }); it("returns the persisted source semantic and content identity with the reopened song", async () => { From 7b2ade1370bda67e28372a090264abd708c5dc14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:19:05 +0900 Subject: [PATCH 329/448] fix(persistence): consolidate desktop core crate root --- apps/desktop/core/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index 4fa841f5d..44f482e73 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -7,7 +7,7 @@ publish = false [lib] name = "bandscope_desktop_core" -path = "src/crate_root.rs" +path = "src/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"] } \ No newline at end of file +uuid = { version = "1", features = ["v4"] } From ce81744e49f3f8ff56d848708f9b0232cbfce245 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:19:15 +0900 Subject: [PATCH 330/448] fix(persistence): integrate project format into canonical core root --- apps/desktop/core/src/root.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/desktop/core/src/root.rs diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs new file mode 100644 index 000000000..bdfae9498 --- /dev/null +++ b/apps/desktop/core/src/root.rs @@ -0,0 +1,32 @@ +//! Pure, GUI-independent logic for the BandScope desktop application. +//! +//! The historical desktop-core implementation remains in `lib.rs` as the +//! compatibility module while bounded resource and persistence boundaries are +//! isolated in auditable modules. Public symbols are re-exported so downstream +//! callers keep one canonical crate-root API. + +#[path = "lib.rs"] +mod runtime_core; +mod audio_resource; +mod content_sha256; +mod project_format; +mod publication_identity; +mod score_pdf; + +pub use audio_resource::{ + copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, + validate_local_audio_file_size, verify_local_audio_publication_receipt, + LocalAudioCopyReceipt, MAX_LOCAL_AUDIO_FILE_BYTES, +}; +pub use content_sha256::sha256_hex_reader; +pub use project_format::{ + project_content_for_document, project_content_for_payload, project_document_from_content, + project_document_from_value, project_payload_from_content, ProjectDocumentPayload, + ProjectPreferencesPayload, ProjectSourceReferencePayload, SelectedPlaybackSourcePayload, + CURRENT_PROJECT_FORMAT_VERSION, +}; +pub use publication_identity::{ + build_local_audio_publication_identity, LocalAudioPublicationIdentity, +}; +pub use runtime_core::*; +pub use score_pdf::read_validated_score_pdf; From 548c46e9f3716229cda3c55318049939fa960760 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:19:24 +0900 Subject: [PATCH 331/448] refactor(persistence): remove duplicate desktop core wrapper --- apps/desktop/core/src/crate_root.rs | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 apps/desktop/core/src/crate_root.rs diff --git a/apps/desktop/core/src/crate_root.rs b/apps/desktop/core/src/crate_root.rs deleted file mode 100644 index a75ef511b..000000000 --- a/apps/desktop/core/src/crate_root.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! 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, ProjectSourceReferencePayload, SelectedPlaybackSourcePayload, - CURRENT_PROJECT_FORMAT_VERSION, -}; From 2a27d308994f2b49af8e4f384eed21898ece4f18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:23:56 +0900 Subject: [PATCH 332/448] fix(persistence): reconcile Resource Admission stack --- CHANGELOG.md | 10 +- apps/desktop/core/src/root.rs | 3 +- apps/desktop/src-tauri/src/main.rs | 168 +++++++++++++++++++++++--- apps/desktop/src/lib/analysis.test.ts | 54 +++++++++ apps/desktop/src/lib/analysis.ts | 44 +++++-- 5 files changed, 252 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b741f419d..61799975c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,18 @@ ### 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 +- Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. +- Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed. +- Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. +- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics. +- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not. +- Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary. +- Treat every zero-element NumPy layout as empty chord input, including shapes whose first dimension is non-zero, before feature extraction. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. - Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. - Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. @@ -84,4 +92,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index bdfae9498..f077db9db 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -6,7 +6,8 @@ //! callers keep one canonical crate-root API. #[path = "lib.rs"] -mod runtime_core; +pub(crate) mod runtime_core; +pub(crate) use runtime_core as core; mod audio_resource; mod content_sha256; mod project_format; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4b811ab9c..173669ea5 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -16,6 +16,16 @@ use std::{ use tauri::{Emitter, Manager, Runtime}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +/// Native-only cache of verified local-audio publication identities. +/// +/// Security Notes: entries are keyed only by BandScope-minted project ids and +/// contain the bounded path-free publication evidence emitted by Resource +/// Admission. User filesystem paths are never retained in this state. +#[derive(Default)] +struct LocalAudioPublicationIdentityState( + std::sync::Mutex>, +); + fn iso_timestamp_now() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -143,7 +153,25 @@ fn app_owned_root( Ok(root) } -fn normalize_local_audio_source(path: &Path) -> Result { +/// Admit one OS-selected local audio file into a project-owned immutable source artifact. +/// +/// Security Notes: the external path is used only to canonicalize and open the +/// user-authorized source. Size is checked from that opened descriptor, bytes +/// are copied through the bounded Resource Admission helper into a private +/// same-project staging file. After the stage is synchronized, publication uses +/// a same-filesystem hard link so an existing `source.` name cannot +/// be overwritten; the private stage name is then removed. The published object +/// is required to remain a regular non-symlink filesystem entry and its opened +/// bytes must reproduce the staging size+SHA-256 receipt before bootstrap +/// authority is returned. This keeps later analysis bound to the app-owned +/// publication rather than the mutable user-selected path. Atomic no-follow +/// descriptor acquisition remains a separate platform-hardening requirement; +/// these portable checks do not claim O_NOFOLLOW-equivalent race semantics. +fn materialize_local_audio_source( + path: &Path, + project_root: &Path, + project_id: &str, +) -> Result<(LocalAudioSourcePayload, LocalAudioPublicationIdentity), String> { let canonical = path .canonicalize() .map_err(|_| "Could not read the selected audio file.".to_string())?; @@ -155,22 +183,109 @@ fn normalize_local_audio_source(path: &Path) -> Result receipt, + Err(error) => { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err(error); + } + }; + if staged.sync_all().is_err() { + drop(staged); + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + drop(staged); - Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: file_name.to_string(), - extension, - file_size_bytes: metadata.len(), - }) + if std::fs::hard_link(&stage, &destination).is_err() { + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + if std::fs::remove_file(&stage).is_err() { + let _ = std::fs::remove_file(&destination); + let _ = std::fs::remove_file(&stage); + return Err("Could not prepare the local project workspace.".to_string()); + } + + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published = match std::fs::File::open(&destination) { + Ok(file) => file, + Err(_) => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + let published_descriptor_metadata = match published.metadata() { + Ok(metadata) if metadata.is_file() && metadata.len() == receipt.file_size_bytes => metadata, + _ => { + drop(published); + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_descriptor_metadata.len() != published_path_metadata.len() + || verify_local_audio_publication_receipt(published, &receipt).is_err() + { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + let published_path_metadata = match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata, + _ => { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + }; + if published_path_metadata.len() != receipt.file_size_bytes { + let _ = std::fs::remove_file(&destination); + return Err("Could not prepare the local project workspace.".to_string()); + } + + let publication_identity = + build_local_audio_publication_identity(project_id, &extension, &receipt)?; + Ok(( + LocalAudioSourcePayload { + source_path: destination.to_string_lossy().into_owned(), + file_name, + extension, + file_size_bytes: receipt.file_size_bytes, + }, + publication_identity, + )) } fn parse_request_payload(payload: Value) -> Result { @@ -306,6 +421,20 @@ fn store_bootstrap_source(state: &AppState, summary: ProjectBootstrapSummaryPayl } } +/// Retain path-free publication evidence before the renderer receives bootstrap authority. +fn store_local_audio_publication_identity( + state: &LocalAudioPublicationIdentityState, + identity: LocalAudioPublicationIdentity, +) -> Result<(), String> { + let project_id = identity.project_id.clone(); + let mut identities = state + .0 + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + identities.insert(project_id, identity); + Ok(()) +} + fn lookup_bootstrap_source( state: &AppState, project_id: &str, @@ -639,16 +768,19 @@ fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) -> fn select_local_audio_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, ) -> Result { let path = FileDialog::new() .add_filter("Audio", &AUDIO_EXTENSIONS) .pick_file() .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?; - let source = normalize_local_audio_source(&path)?; let project_id = next_project_id(&state); let project_root = app_owned_root(&app, "projects", &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; let temp_root = app_owned_root(&app, "temp", &project_id)?; + let (source, publication_identity) = + materialize_local_audio_source(&path, &project_root, &project_id)?; + store_local_audio_publication_identity(&publication_state, publication_identity)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -714,6 +846,7 @@ async fn import_youtube_url( if parsed.get("ok").and_then(|v| v.as_bool()) == Some(true) { if let Some(metadata) = parsed.get("metadata") { let source = youtube_source_from_metadata(metadata, &cache_root)?; + validate_local_audio_file_size(source.file_size_bytes)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -824,7 +957,9 @@ fn attach_score_pdf( /// Security Notes: no path crosses the IPC boundary. Both ids are validated /// against strict allowlist shapes, the path is rebuilt locally, and the /// canonicalize-plus-prefix guard in `resolve_existing_score_pdf` rejects any -/// escape from the app-owned scores root. +/// escape from the app-owned scores root. The resolved file is then read +/// through the bounded core helper so growth after attachment cannot trigger +/// an allocation beyond the 25 MiB product limit. #[tauri::command] fn read_score_pdf( project_id: String, @@ -836,7 +971,7 @@ fn read_score_pdf( } let scores_root = scores_root_for_project(&app, &project_id)?; let path = resolve_existing_score_pdf(&scores_root, &score_id)?; - std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) + read_validated_score_pdf(&path) } /// Security Notes: same id validation and traversal guard as `read_score_pdf`; @@ -866,6 +1001,7 @@ fn remove_score_pdf( fn main() { tauri::Builder::default() .manage(AppState::default()) + .manage(LocalAudioPublicationIdentityState::default()) .invoke_handler(tauri::generate_handler![ select_local_audio_source, import_youtube_url, diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index 06b804b20..4fb47f211 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; import { + MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, @@ -14,6 +15,7 @@ type TauriWindow = Window & { }; const tauriWindow = window as TauriWindow; +const OVERSIZED_LOCAL_AUDIO_NEXT_ACTION = "Choose a shorter or smaller song file to start analysis."; describe("analysis bridge", () => { beforeEach(() => { @@ -21,6 +23,58 @@ describe("analysis bridge", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("rejects an oversized native local-audio selection before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-local-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-local-project", + cacheRoot: "/tmp/bandscope/cache/native-local-project", + tempRoot: "/tmp/bandscope/temp/native-local-project", + source: { + sourcePath: "/tmp/bandscope/input.wav", + fileName: "input.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION + } + }); + }); + + it("rejects an oversized native YouTube import before it becomes project state", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + projectId: "native-youtube-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/native-youtube-project", + cacheRoot: "/tmp/bandscope/cache/native-youtube-project", + tempRoot: "/tmp/bandscope/temp/native-youtube-project", + source: { + sourcePath: "/tmp/bandscope/temp/native-youtube-project/youtube.wav", + fileName: "youtube.wav", + extension: "wav", + fileSizeBytes: MAX_LOCAL_AUDIO_FILE_BYTES + 1 + } + }); + + const selection = await importYoutubeUrl("https://youtu.be/4ozX4yFUC34"); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: OVERSIZED_LOCAL_AUDIO_NEXT_ACTION + } + }); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index dd3b01567..26836b99f 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -43,8 +43,14 @@ const BROWSER_PROGRESS_STEPS = [ { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const LOCAL_AUDIO_TOO_LARGE_MESSAGE = "Choose a shorter or smaller song file to start analysis."; +const LOCAL_AUDIO_POLICY_MESSAGE = + "Selected audio file metadata violates the analysis resource policy."; +const MAX_LOCAL_AUDIO_FILE_BYTES = 100 * 1024 * 1024; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, + LOCAL_AUDIO_TOO_LARGE_MESSAGE, + LOCAL_AUDIO_POLICY_MESSAGE, "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", @@ -53,7 +59,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; -export { MAX_YOUTUBE_URL_LENGTH }; +export { MAX_LOCAL_AUDIO_FILE_BYTES, MAX_YOUTUBE_URL_LENGTH }; /** Documented. */ export type LocalAudioSelectionResult = @@ -225,7 +231,7 @@ async function invokeAnalysis(command: string, args?: Record): return browserFallback(command, args); } -/** Preserve only the bounded native intake messages approved for buyer-visible diagnostics. */ +/** Preserve only bounded native intake diagnostics approved for buyer-visible display. */ function localAudioErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; return message && SAFE_LOCAL_AUDIO_MESSAGES.has(message) @@ -233,6 +239,26 @@ function localAudioErrorMessage(error: unknown): string { : UNSUPPORTED_LOCAL_AUDIO_MESSAGE; } +/** + * Parse a native/import bootstrap and enforce policy-v1 encoded-byte parity + * before the selection is allowed to become desktop project state. + * + * Python service and descriptor checks remain authoritative for analysis; this + * bridge check is defense in depth so local-file and imported-file intake fail + * at the same 100 MiB boundary instead of waiting for a later analysis stage. + */ +function parseBoundedAudioBootstrap(response: unknown): ProjectBootstrapSummary { + const bootstrap = parseProjectBootstrapSummary(response); + const fileSizeBytes = bootstrap.source.fileSizeBytes; + if (!Number.isSafeInteger(fileSizeBytes)) { + throw new Error(LOCAL_AUDIO_POLICY_MESSAGE); + } + if (fileSizeBytes > MAX_LOCAL_AUDIO_FILE_BYTES) { + throw new Error(LOCAL_AUDIO_TOO_LARGE_MESSAGE); + } + return bootstrap; +} + /** Documented. */ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); @@ -244,7 +270,7 @@ export async function selectLocalAudioSource(): Promise { const parsedDocument = parseProjectDocument(projectDocument); @@ -384,7 +410,7 @@ export async function saveProject( await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource)); } -/** Compatibility load for existing song-only consumers while mounted reopen composition remains #962/#1160 work. */ +/** Compatibility load for existing song-only consumers while mounted reopen composition remains separate work. */ export async function loadProject(): Promise { return (await loadProjectDocument()).song; } From 649e33586ec73f85fe16c53924edb1ddc497b08a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:30:04 +0900 Subject: [PATCH 333/448] docs(core): explain canonical compatibility alias --- apps/desktop/core/src/root.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index f077db9db..925137249 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -7,6 +7,9 @@ #[path = "lib.rs"] pub(crate) mod runtime_core; +// Project Persistence still imports `crate::core`; keep that name as a +// crate-private alias to the same compatibility module instead of restoring a +// second crate root or copying Resource Admission ownership. pub(crate) use runtime_core as core; mod audio_resource; mod content_sha256; From 3a96be63445aba40b52c6a086ff8e4ee0623f466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:43:20 +0900 Subject: [PATCH 334/448] test(project): require typed resource-admission persistence handoff --- ...oject_format_resource_admission_handoff.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/desktop/core/tests/project_format_resource_admission_handoff.rs diff --git a/apps/desktop/core/tests/project_format_resource_admission_handoff.rs b/apps/desktop/core/tests/project_format_resource_admission_handoff.rs new file mode 100644 index 000000000..82d90eff5 --- /dev/null +++ b/apps/desktop/core/tests/project_format_resource_admission_handoff.rs @@ -0,0 +1,58 @@ +use bandscope_desktop_core::{ + build_local_audio_publication_identity, project_source_reference_from_publication_identity, + LocalAudioCopyReceipt, LocalAudioPublicationIdentity, +}; + +const CONTENT_SHA256: &str = + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"; + +fn verified_identity() -> LocalAudioPublicationIdentity { + build_local_audio_publication_identity( + "project-400-4", + "flac", + &LocalAudioCopyReceipt { + file_size_bytes: 8192, + content_sha256: CONTENT_SHA256.to_string(), + }, + ) + .expect("Resource Admission fixture should be valid") +} + +#[test] +fn projects_verified_native_publication_identity_into_path_free_persistence_evidence() { + let identity = verified_identity(); + let reference = project_source_reference_from_publication_identity(&identity) + .expect("verified native identity should cross the persistence ACL"); + + assert_eq!(reference.project_id, "project-400-4"); + assert_eq!(reference.artifact_name, "source.flac"); + assert_eq!(reference.extension, "flac"); + assert_eq!(reference.file_size_bytes, 8192); + assert_eq!(reference.content_sha256, CONTENT_SHA256); + + let serialized = serde_json::to_value(reference).expect("source reference should serialize"); + let keys = serialized + .as_object() + .expect("source reference should serialize as an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "artifactName", + "contentSha256", + "extension", + "fileSizeBytes", + "projectId", + ]) + ); +} + +#[test] +fn rejects_forged_identity_at_the_resource_admission_to_persistence_acl() { + let mut forged = verified_identity(); + forged.artifact_name = "../source.flac".to_string(); + + assert!(project_source_reference_from_publication_identity(&forged).is_err()); +} From c2d3541ff3f1a3108adc6224b1dd565fa2a1bd83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:43:57 +0900 Subject: [PATCH 335/448] fix(project): add typed resource-admission persistence ACL --- apps/desktop/core/src/project_format.rs | 53 +++++++++++++++++-------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/desktop/core/src/project_format.rs b/apps/desktop/core/src/project_format.rs index 76749742e..444ad5e15 100644 --- a/apps/desktop/core/src/project_format.rs +++ b/apps/desktop/core/src/project_format.rs @@ -6,9 +6,13 @@ //! song parser remains the migration authority for historical inputs; this //! module owns the current envelope presented to external crate consumers. -use crate::core::{ - is_valid_project_id, project_payload_from_content as project_v1_payload_from_content, - RehearsalSongPayload, AUDIO_EXTENSIONS, +use crate::{ + audio_resource::MAX_LOCAL_AUDIO_FILE_BYTES, + core::{ + is_valid_project_id, project_payload_from_content as project_v1_payload_from_content, + RehearsalSongPayload, AUDIO_EXTENSIONS, + }, + publication_identity::LocalAudioPublicationIdentity, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -16,14 +20,6 @@ use serde_json::Value; /// Current on-disk project format version, independent of the app version. pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 3; -/// Maximum admitted full-mix bytes a durable source reference may claim. -/// -/// This mirrors the current Resource Admission ceiling so an untrusted project -/// document cannot turn an impossible source identity into restart authority. -/// Once the #866 foundation enters this branch's ancestry, consume its exported -/// canonical constant instead of maintaining two declarations. -const MAX_PROJECT_SOURCE_REFERENCE_BYTES: u64 = 100 * 1024 * 1024; - /// Stable playback-source identity stored in project preferences. /// /// These values describe rehearsal intent. They are resolved against current @@ -129,7 +125,7 @@ fn sha256_hex_is_canonical(value: &str) -> bool { fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool { if !is_valid_project_id(&reference.project_id) || reference.file_size_bytes == 0 - || reference.file_size_bytes > MAX_PROJECT_SOURCE_REFERENCE_BYTES + || reference.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES || !AUDIO_EXTENSIONS.contains(&reference.extension.as_str()) || !sha256_hex_is_canonical(&reference.content_sha256) { @@ -140,6 +136,31 @@ fn source_reference_is_valid(reference: &ProjectSourceReferencePayload) -> bool reference.artifact_name == expected_artifact_name } +/// Project verified Resource Admission evidence into the durable Project Persistence schema. +/// +/// Security Notes: this is the anti-corruption layer between the two bounded +/// contexts. It copies only the path-free identity fields and re-validates the +/// resulting Project Persistence reference before serialization. This matters +/// even for a typed input because internal callers or deserialization can still +/// construct a `LocalAudioPublicationIdentity` without going through the +/// Resource Admission builder. User filesystem paths and playback capabilities +/// therefore cannot cross this handoff. +pub fn project_source_reference_from_publication_identity( + identity: &LocalAudioPublicationIdentity, +) -> Result { + let reference = ProjectSourceReferencePayload { + project_id: identity.project_id.clone(), + artifact_name: identity.artifact_name.clone(), + extension: identity.extension.clone(), + file_size_bytes: identity.file_size_bytes, + content_sha256: identity.content_sha256.clone(), + }; + if !source_reference_is_valid(&reference) { + return Err("Invalid project document payload".to_string()); + } + Ok(reference) +} + fn validate_document(document: ProjectDocumentPayload) -> Result { if document .source_reference @@ -157,10 +178,10 @@ fn validate_document(document: ProjectDocumentPayload) -> Result Result { let document = serde_json::from_value::(value) .map_err(|_| "Invalid project document payload".to_string())?; From b0355a98d03c0e2128f0f1b8b79df9f779514d96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:44:04 +0900 Subject: [PATCH 336/448] fix(project): export resource-admission persistence ACL --- apps/desktop/core/src/root.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 925137249..b50c56144 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -25,7 +25,8 @@ pub use audio_resource::{ pub use content_sha256::sha256_hex_reader; pub use project_format::{ project_content_for_document, project_content_for_payload, project_document_from_content, - project_document_from_value, project_payload_from_content, ProjectDocumentPayload, + project_document_from_value, project_payload_from_content, + project_source_reference_from_publication_identity, ProjectDocumentPayload, ProjectPreferencesPayload, ProjectSourceReferencePayload, SelectedPlaybackSourcePayload, CURRENT_PROJECT_FORMAT_VERSION, }; From 6eea76fdb138838d61e8af0d23ea69d99012de21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:01:47 +0900 Subject: [PATCH 337/448] test(project): fail closed on browser save preview --- .../src/lib/projectDocumentSaveAuthority.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts index 0898766ae..fcf6c646d 100644 --- a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts +++ b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts @@ -17,6 +17,15 @@ describe("project document save authority", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("fails closed when browser preview has no durable project-save authority", async () => { + await expect( + saveProjectDocument({ + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" } + }) + ).rejects.toThrow("Local project save is not available in browser preview."); + }); + it("rejects renderer-authored source identity before persistence IPC", async () => { const invoke = vi.fn().mockResolvedValue(undefined); tauriWindow.__TAURI_INVOKE__ = invoke; @@ -37,4 +46,4 @@ describe("project document save authority", () => { expect(invoke).not.toHaveBeenCalled(); }); -}); +}); \ No newline at end of file From cb7f4fd956278d1273e6be3f2df367171baadf9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:03:03 +0900 Subject: [PATCH 338/448] fix(project): fail closed on browser save preview --- apps/desktop/src/lib/analysis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 26836b99f..ae71d2cdd 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -191,7 +191,7 @@ async function browserFallback(command: string, args?: Record): } if (command === "save_project") { - return; + throw new Error("Local project save is not available in browser preview."); } if (command === "import_youtube_url") { From e29f0de75a62c7686e0e83c9769532f7d5f80b1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:03:49 +0900 Subject: [PATCH 339/448] docs(project): align IPC traceability with native source authority --- docs/traceability/project-v2-ipc-bridge.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 12fd43b69..ea78e3a25 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -8,12 +8,15 @@ Project Persistence had a strict v2 document and durable `preferences.selectedPl Later review found that renderer admission accepted custom-prototype objects, then that Proxy own-key/descriptor traps and accessor-backed fields could escape the stable validation contract or execute application-controlled getters. Those executable JavaScript shapes cannot originate from parsed JSON and have no durable `.bscope` meaning. +A further browser-preview review found a separate buyer-truth defect: when Tauri was absent, the browser fallback returned success for `save_project` even though no project bytes were persisted anywhere. Preview/browser tests could therefore observe a false successful-save outcome that production desktop persistence never performed. + ## Constraints - Project Persistence remains the only durable `.bscope` authority. - Playback selection persists only as a stable semantic; filesystem paths, native capability URLs, generation tokens and discovery receipts remain outside preference state. - Song-only callers remain compatibility adapters and deterministically default to `full_mix` when they do not own a selection. - Unknown fields, prototype-bearing records, accessors, enumeration/descriptor traps and runtime-authority strings fail closed before persistence IPC; native admission repeats the typed boundary. +- Browser preview has no durable project-file authority and must fail closed for Save/Load rather than simulate successful persistence. - The bridge does not itself make a reopened stem playable. Stored intent must be combined with freshly re-admitted native audio availability. ## RED → fix evidence @@ -24,25 +27,28 @@ Later review found that renderer admission accepted custom-prototype objects, th - `3db1096baa52de34baa7fea4c1638185914d22b7` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. - `a71439d82932f671d8079c5f7c78b401679dcb6b` added Proxy/accessor REDs. `bc8e144355353e6311425afe734dfcf8e282ccd5` made exact-key enumeration exception-safe and required own enumerable data properties; `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` added nested selection-accessor coverage. - The later v3 source-reference extension preserves the same passive-record boundary: `f54be004887c11cd7a00065b7db86510e5c83ee8` adds the renderer source-reference contract, `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` closes optional descriptor traps, and `c1cdcd036749a0a9231682db9446e5fbbe410d40` verifies source-reference getters/traps are not executed. +- Browser-persistence RED `6eea76fdb138838d61e8af0d23ea69d99012de21` requires Save without a Tauri invoke bridge to reject instead of reporting a success that wrote no bytes. Fix `cb7f4fd956278d1273e6be3f2df367171baadf9e` makes the browser fallback fail closed with `Local project save is not available in browser preview.` while leaving native Tauri persistence unchanged. ## Alternatives rejected -Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. +Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. Pretending browser preview persisted a project was rejected because it produces unverifiable buyer-facing success and can make browser E2E pass without exercising the desktop durability boundary. ## Security Notes **Attack surface.** Renderer IPC and reopened `.bscope` JSON are untrusted. Playback capabilities and any source locator are also untrusted and must not become durable authority merely because the renderer sees them. -**Trust boundary.** TypeScript validates the current project document before invoke and after load; Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. +**Trust boundary.** TypeScript validates the current project document before invoke and after load; Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. A browser preview without the Tauri bridge is outside the durable project-file boundary and cannot claim Save success. -**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. +**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. Browser fallback rejects project Save/Load instead of creating a second in-memory persistence truth. -**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, and invalid path-shaped reference fields. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. +**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, invalid path-shaped reference fields, renderer-authored source-reference rejection before IPC, and browser-preview Save fail-closed behavior. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. **Logging/privacy.** Rejected object contents, trap text, local paths and project payloads are not forwarded as validation output. The public renderer error remains bounded rather than echoing attacker-controlled exceptions. ## Current effect and remaining risk -The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. +The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. Browser preview no longer reports a successful project Save when it has no durable file authority. + +Resource Admission #866 now materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 has ordinarily adopted that implementation and exposes the typed `project_source_reference_from_publication_identity` ACL. The remaining v3 source-persistence gap is narrower: native `save_project` still does not look up the retained identity by an explicit project id and inject the resulting `sourceReference` immediately before serialization, and restart still does not re-admit the app-owned artifact to reconstruct fresh bootstrap/playback authority. #1160 may resolve persisted `selectedPlaybackSource` only after that fresh authority exists. -Process-restart playback is nevertheless still incomplete. Current Resource Admission references the externally selected absolute source path and keeps bootstrap state in memory, while mounted project load clears `jobResultBootstrap`. #970/#962 must materialize the admitted full mix under the app-owned project namespace and reconstruct a fresh bootstrap from the validated v3 source reference. #1160 can then resolve the persisted semantic against fresh stem availability and fail closed to Full mix if the preferred stem no longer exists. Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery and independent exact-head review evidence remain release gates. +Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery, downgrade/application rollback, restart source re-admission and independent exact-head review evidence remain release gates. From cd3c67de8c9d35355aa30950733a1cf24a5d23fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:04:54 +0900 Subject: [PATCH 340/448] test(project): require native source-reference save adapter --- .../tests/local_audio_publication_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index f818d0e0b..de4b17ec9 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -71,3 +71,41 @@ fn local_audio_selection_retains_verified_path_free_identity_in_native_state() { "the native publication identity state must be registered with the Tauri runtime" ); } + +#[test] +fn project_save_binds_only_explicit_project_id_to_retained_native_source_identity() { + let source = include_str!("../src/main.rs"); + let save_start = source + .find("fn save_project(") + .expect("native project save command must remain present"); + let save_tail = &source[save_start..]; + let save_end = save_tail + .find("\n}\n\n#[tauri::command]\nfn load_project") + .expect("save command boundary must remain inspectable"); + let save_command = &save_tail[..save_end]; + + assert!( + source.contains("fn project_document_with_retained_source_reference("), + "native persistence needs one explicit retained-identity adapter" + ); + assert!( + save_command.contains("project_id: Option"), + "renderer may submit only the already-minted project id as the save selector" + ); + assert!( + save_command.contains("publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>"), + "save must read verified source identity from native state instead of renderer evidence" + ); + assert!( + save_command.contains("project_document_with_retained_source_reference("), + "save must inject the native source reference before project serialization" + ); + assert!( + !save_command.contains("source_reference = serde_json"), + "save must never reconstruct source identity from renderer JSON" + ); + assert!( + !source.contains("last_selected_project"), + "multiple project aggregates forbid a global last-selected shortcut" + ); +} From ffdac30e63c4faa7264416bbeec8570a0c6543ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:06:32 +0900 Subject: [PATCH 341/448] fix(project): inject retained native source identity on save --- apps/desktop/src-tauri/src/main.rs | 49 +++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 173669ea5..014e12a2d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -435,6 +435,44 @@ fn store_local_audio_publication_identity( Ok(()) } +/// Bind renderer-owned project state to an already-verified Resource Admission identity. +/// +/// Security Notes: the renderer can select only a BandScope-minted project id. +/// It cannot submit a path, artifact name, byte count, digest, or sourceReference. +/// The exact keyed native identity is revalidated through the Project Persistence +/// ACL before serialization. An unknown or malformed id fails closed; omitting +/// the selector preserves compatibility for projects that have no admitted local +/// source identity yet. +fn project_document_with_retained_source_reference( + mut document: ProjectDocumentPayload, + project_id: Option<&str>, + state: &LocalAudioPublicationIdentityState, +) -> Result { + let Some(project_id) = project_id else { + return Ok(document); + }; + if !is_valid_project_id(project_id) { + return Err("Invalid project payload".to_string()); + } + + let identity = state + .0 + .lock() + .map_err(|_| "Invalid project payload".to_string())? + .get(project_id) + .cloned() + .ok_or_else(|| "Invalid project payload".to_string())?; + if identity.project_id != project_id { + return Err("Invalid project payload".to_string()); + } + + document.source_reference = Some( + project_source_reference_from_publication_identity(&identity) + .map_err(|_| "Invalid project payload".to_string())?, + ); + Ok(document) +} + fn lookup_bootstrap_source( state: &AppState, project_id: &str, @@ -874,9 +912,18 @@ async fn import_youtube_url( } #[tauri::command] -fn save_project(payload: Value) -> Result<(), String> { +fn save_project( + payload: Value, + project_id: Option, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, +) -> Result<(), String> { let parsed = project_document_from_value(payload) .map_err(|_| "Invalid project payload".to_string())?; + let parsed = project_document_with_retained_source_reference( + parsed, + project_id.as_deref(), + &publication_state, + )?; let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) From 570894b91f69b0e24c26090309fe5ee5d414f514 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:06:54 +0900 Subject: [PATCH 342/448] test(project): require explicit project-id save selector --- .../src/lib/projectDocumentSaveAuthority.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts index fcf6c646d..9519f3d5d 100644 --- a/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts +++ b/apps/desktop/src/lib/projectDocumentSaveAuthority.test.ts @@ -26,6 +26,22 @@ describe("project document save authority", () => { ).rejects.toThrow("Local project save is not available in browser preview."); }); + it("forwards only an explicit project-id selector beside renderer-owned save state", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + tauriWindow.__TAURI_INVOKE__ = invoke; + const document = { + song: createDemoRehearsalSong(), + preferences: { selectedPlaybackSource: "full_mix" as const } + }; + + await saveProjectDocument(document, "project-400-4"); + + expect(invoke).toHaveBeenCalledWith("save_project", { + payload: document, + projectId: "project-400-4" + }); + }); + it("rejects renderer-authored source identity before persistence IPC", async () => { const invoke = vi.fn().mockResolvedValue(undefined); tauriWindow.__TAURI_INVOKE__ = invoke; From 979ac4d3a948ab76a01e076fa29bece6161489d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:07:40 +0900 Subject: [PATCH 343/448] fix(project): forward explicit project-id save selector --- apps/desktop/src/lib/analysis.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index ae71d2cdd..0c820c92b 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -385,15 +385,22 @@ export async function importYoutubeUrl(url: string): Promise { +export async function saveProjectDocument( + projectDocument: ProjectDocument, + projectId?: string +): Promise { const parsedDocument = parseProjectDocument(projectDocument); if (parsedDocument.sourceReference) { throw new Error("Invalid project document"); } - await invokeAnalysis("save_project", { payload: parsedDocument }); + await invokeAnalysis("save_project", { + payload: parsedDocument, + ...(projectId === undefined ? {} : { projectId }) + }); } /** Reopen one current versioned project document, including durable Project Persistence state. */ @@ -405,9 +412,10 @@ export async function loadProjectDocument(): Promise { /** Compatibility save for callers that do not yet own a playback-source preference. */ export async function saveProject( song: RehearsalSong, - selectedPlaybackSource: SelectedPlaybackSource = "full_mix" + selectedPlaybackSource: SelectedPlaybackSource = "full_mix", + projectId?: string ): Promise { - await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource)); + await saveProjectDocument(createProjectDocument(song, selectedPlaybackSource), projectId); } /** Compatibility load for existing song-only consumers while mounted reopen composition remains separate work. */ From 28d94d0e9566030c53370829484f808f6763fbcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:08:59 +0900 Subject: [PATCH 344/448] test(project): require mounted local save project identity --- ...App.project-save-source-authority.test.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/desktop/src/App.project-save-source-authority.test.tsx diff --git a/apps/desktop/src/App.project-save-source-authority.test.tsx b/apps/desktop/src/App.project-save-source-authority.test.tsx new file mode 100644 index 000000000..5e6de82c8 --- /dev/null +++ b/apps/desktop/src/App.project-save-source-authority.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { vi, describe, it, expect } from "vitest"; +import { App } from "./App"; + +const { mockSaveProject } = vi.hoisted(() => ({ + mockSaveProject: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +vi.mock("./lib/analysis", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar"] + }), + selectLocalAudioSource: async () => ({ + ok: true as const, + bootstrap: { + projectId: "project-400-4", + sourceMode: "reference" as const, + projectRoot: "/tmp/bandscope/projects/project-400-4", + cacheRoot: "/tmp/bandscope/cache/project-400-4", + tempRoot: "/tmp/bandscope/temp/project-400-4", + source: { + sourcePath: "/tmp/bandscope/projects/project-400-4/source.wav", + fileName: "source.wav", + extension: "wav", + fileSizeBytes: 4096 + } + } + }), + startAnalysisJob: async () => ({ + jobId: "job-local-save", + state: "succeeded" as const, + requestedAt: "2026-09-06T08:00:00Z", + updatedAt: "2026-09-06T08:00:01Z", + progressLabel: "Analysis ready", + progressStage: "ready" as const, + progressPercent: 100, + result: createDemoRehearsalSong() + }), + subscribeToAnalysisJobUpdates: async () => () => undefined, + saveProject: (...args: unknown[]) => mockSaveProject(...args) + }; +}); + +describe("App local-audio save authority", () => { + it("saves the analyzed local project with its exact native project id", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText("source.wav")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + await waitFor(() => expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "full_mix", + "project-400-4" + ); + }); + }); +}); From 06afcbe030ef1fb8d6b2097be0e40bc4d5c7c03a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:13:04 +0900 Subject: [PATCH 345/448] fix(project): preserve analyzed local project save authority --- apps/desktop/src/App.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..fc47f68fa 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -254,11 +254,14 @@ export function App() { const [jobStatus, setJobStatus] = useState(null); const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); + const [jobResultPublicationProjectId, setJobResultPublicationProjectId] = useState(null); const [jobError, setJobError] = useState(null); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); + const [selectedPublicationProjectId, setSelectedPublicationProjectId] = useState(null); const [activeAnalysisBootstrap, setActiveAnalysisBootstrap] = useState(null); + const [activeAnalysisPublicationProjectId, setActiveAnalysisPublicationProjectId] = useState(null); const [selectionError, setSelectionError] = useState(null); const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); const [youtubeUrl, setYoutubeUrl] = useState(""); @@ -287,14 +290,17 @@ export function App() { if (nextStatus.state === "succeeded" && nextStatus.result) { setJobResult(nextStatus.result); setJobResultBootstrap(activeAnalysisBootstrap); + setJobResultPublicationProjectId(activeAnalysisPublicationProjectId); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(null); } if (nextStatus.state === "failed") { setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart"))); } - }, [activeAnalysisBootstrap, t]); + }, [activeAnalysisBootstrap, activeAnalysisPublicationProjectId, t]); useEffect(() => { const targetPercent = jobStatus?.progressPercent; @@ -388,11 +394,14 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; + const submittedPublicationProjectId = selectedPublicationProjectId; setJobError(null); setJobResult(null); setJobResultBootstrap(null); + setJobResultPublicationProjectId(null); setJobStatus(null); setActiveAnalysisBootstrap(submittedBootstrap); + setActiveAnalysisPublicationProjectId(submittedPublicationProjectId); setIsStarting(true); try { const nextStatus = await startAnalysisJob(selectedRequest); @@ -400,13 +409,16 @@ export function App() { setJobStatus(nextStatus); setJobResult(nextStatus.result); setJobResultBootstrap(submittedBootstrap); + setJobResultPublicationProjectId(submittedPublicationProjectId); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); } else { applyJobStatus(nextStatus); } } catch { setJobStatus(null); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobError(t("analysisCouldNotStart")); } finally { setIsStarting(false); @@ -420,10 +432,12 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setSelectedPublicationProjectId(selection.bootstrap.projectId); return; } setSelectedBootstrap(null); + setSelectedPublicationProjectId(null); setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); setSelectionErrorSource("local"); setJobStatus(null); @@ -451,6 +465,7 @@ export function App() { const selection = await importYoutubeUrl(normalizedUrl); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setSelectedPublicationProjectId(null); setYoutubeUrl(""); } else { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); @@ -476,9 +491,12 @@ export function App() { const song = await loadProject(); setJobResult(song); setJobResultBootstrap(null); + setJobResultPublicationProjectId(null); setJobError(null); setSelectedBootstrap(null); + setSelectedPublicationProjectId(null); setActiveAnalysisBootstrap(null); + setActiveAnalysisPublicationProjectId(null); setJobStatus(null); } catch (e) { if (!isUserCancellation(e)) { @@ -490,7 +508,11 @@ export function App() { /** Documented. */ const handleSaveProject = async () => { try { - await saveProject(jobResult!); + if (jobResultPublicationProjectId) { + await saveProject(jobResult!, "full_mix", jobResultPublicationProjectId); + } else { + await saveProject(jobResult!); + } } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); From 4869ee85f475ac43010fcd7bec1741acfe2aea01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:13:46 +0900 Subject: [PATCH 346/448] docs(project): record native source-reference save handoff --- docs/traceability/project-v2-ipc-bridge.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index ea78e3a25..85db55ca3 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -10,9 +10,14 @@ Later review found that renderer admission accepted custom-prototype objects, th A further browser-preview review found a separate buyer-truth defect: when Tauri was absent, the browser fallback returned success for `save_project` even though no project bytes were persisted anywhere. Preview/browser tests could therefore observe a false successful-save outcome that production desktop persistence never performed. +After Resource Admission entered #970 ancestry, a second handoff defect remained: native selection retained a verified `LocalAudioPublicationIdentity`, but Save neither accepted an explicit project selector nor injected that retained identity into v3 immediately before serialization. The mounted App also lost the aggregate id between local-audio analysis and Save. + ## Constraints - Project Persistence remains the only durable `.bscope` authority. +- Resource Admission remains the owner of app-owned local-audio bytes, bounded byte evidence and SHA-256 publication identity. +- Renderer may select only an already-minted BandScope project id for Save; it may not submit a path, artifact name, byte count, digest, or `sourceReference`. +- Multiple project aggregates can coexist, so a global last-selected-project shortcut is not valid authority. - Playback selection persists only as a stable semantic; filesystem paths, native capability URLs, generation tokens and discovery receipts remain outside preference state. - Song-only callers remain compatibility adapters and deterministically default to `full_mix` when they do not own a selection. - Unknown fields, prototype-bearing records, accessors, enumeration/descriptor traps and runtime-authority strings fail closed before persistence IPC; native admission repeats the typed boundary. @@ -24,24 +29,27 @@ A further browser-preview review found a separate buyer-truth defect: when Tauri - `ecc2904f55516806b51baa4bbafeef9d700b058c` added the renderer bridge RED for all five stable semantics, round-trip load, runtime-authority rejection and unknown preference fields. - `30bfa590df61a2b031076af81010f3e5f31372ea` added the TypeScript Project Persistence anti-corruption boundary; `64613fbb604c4ddc6d156c84bc520dd8d40cef19` wired `saveProjectDocument`/`loadProjectDocument` through the existing Tauri command boundary. - `7f9d118b08038fd5473b71f0a1243136b39e04bc` changed native `save_project` to strict current-document admission and `load_project` to return the typed current document. `327c83f86c1ed213a1f6a58d382715e744ab9831` immediately reverted an unrelated transient score-root edit found during review. -- `3db1096baa52de34baa7fea4c1638185914d22b7` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. +- `3db1096baa52de34baa7fea82b7b09938aa3970a` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. - `a71439d82932f671d8079c5f7c78b401679dcb6b` added Proxy/accessor REDs. `bc8e144355353e6311425afe734dfcf8e282ccd5` made exact-key enumeration exception-safe and required own enumerable data properties; `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` added nested selection-accessor coverage. - The later v3 source-reference extension preserves the same passive-record boundary: `f54be004887c11cd7a00065b7db86510e5c83ee8` adds the renderer source-reference contract, `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` closes optional descriptor traps, and `c1cdcd036749a0a9231682db9446e5fbbe410d40` verifies source-reference getters/traps are not executed. - Browser-persistence RED `6eea76fdb138838d61e8af0d23ea69d99012de21` requires Save without a Tauri invoke bridge to reject instead of reporting a success that wrote no bytes. Fix `cb7f4fd956278d1273e6be3f2df367171baadf9e` makes the browser fallback fail closed with `Local project save is not available in browser preview.` while leaving native Tauri persistence unchanged. +- Native handoff RED `cd3c67de8c9d35355aa30950733a1cf24a5d23fc` requires `save_project` to accept only an explicit optional project id, read `LocalAudioPublicationIdentityState`, use the typed Resource Admission → Project Persistence ACL, and avoid a global last-selected shortcut. Fix `ffdac30e63c4faa7264416bbeec8570a0c6543ff` adds `project_document_with_retained_source_reference`, performs exact native lookup by project id, revalidates through `project_source_reference_from_publication_identity`, and injects the result before serialization. +- Renderer-selector RED `570894b91f69b0e24c26090309fe5ee5d414f514` requires `saveProjectDocument(document, projectId)` to send only `{ payload, projectId }`. Fix `979ac4d3a948ab76a01e076fa29bece6161489d6` adds that optional selector while retaining the renderer-authored `sourceReference` rejection. +- Mounted-flow RED `28d94d0e9566030c53370829484f808f6763fbcf` requires an analyzed OS-selected local project to Save with its exact minted project id. Fix `06afcbe030ef1fb8d6b2097be0e40bc4d5c7c03a` tracks the local publication project id separately from generic bootstrap state, binds it to the submitted analysis result, clears it on load/failure/YouTube replacement, and passes it only when saving that local result. This avoids binding an old result to a newer selection and avoids sending YouTube ids that have no `LocalAudioPublicationIdentityState` entry. ## Alternatives rejected -Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. Pretending browser preview persisted a project was rejected because it produces unverifiable buyer-facing success and can make browser E2E pass without exercising the desktop durability boundary. +Persisting the opaque playback URL was rejected because its authority is intentionally revocable. `localStorage` was rejected as a second writable project truth. Adding selection to `RehearsalSong` was rejected because it is UI/project preference, not MIR evidence. Arbitrary class/Proxy/accessor objects were rejected because executable object behavior has no `.bscope` semantics. Replacing compatibility APIs outright was rejected because unrelated callers do not necessarily own Active Player state. Pretending browser preview persisted a project was rejected because it produces unverifiable buyer-facing success and can make browser E2E pass without exercising the desktop durability boundary. A native or renderer-global last-selected project was rejected because it is stale-race-prone and cannot distinguish multiple aggregates. Sending a full `sourceReference` from the WebView was rejected because it would let renderer data impersonate Resource Admission evidence. ## Security Notes **Attack surface.** Renderer IPC and reopened `.bscope` JSON are untrusted. Playback capabilities and any source locator are also untrusted and must not become durable authority merely because the renderer sees them. -**Trust boundary.** TypeScript validates the current project document before invoke and after load; Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. A browser preview without the Tauri bridge is outside the durable project-file boundary and cannot claim Save success. +**Trust boundary.** TypeScript validates the current project document before invoke and after load. For local-audio Save, the WebView can add only the already-minted project id selector. Tauri performs exact lookup in native `LocalAudioPublicationIdentityState`, revalidates the identity through the Project Persistence ACL, and injects the path-free `sourceReference` before serialization. Rust repeats strict typed admission before filesystem mutation and after bounded read. Active Player/resource admission mints runtime playback authority later. A browser preview without the Tauri bridge is outside the durable project-file boundary and cannot claim Save success. -**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. Browser fallback rejects project Save/Load instead of creating a second in-memory persistence truth. +**Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. Browser fallback rejects project Save/Load instead of creating a second in-memory persistence truth. Local result-to-project association is captured at analysis submission rather than read from whatever source happens to be selected at Save time. -**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, invalid path-shaped reference fields, renderer-authored source-reference rejection before IPC, and browser-preview Save fail-closed behavior. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. +**Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, invalid path-shaped reference fields, renderer-authored source-reference rejection before IPC, browser-preview Save fail-closed behavior, explicit project-id selector forwarding, native retained-identity lookup/injection, and mounted local-audio analysis → Save identity continuity. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. **Logging/privacy.** Rejected object contents, trap text, local paths and project payloads are not forwarded as validation output. The public renderer error remains bounded rather than echoing attacker-controlled exceptions. @@ -49,6 +57,8 @@ Persisting the opaque playback URL was rejected because its authority is intenti The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. Browser preview no longer reports a successful project Save when it has no durable file authority. -Resource Admission #866 now materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 has ordinarily adopted that implementation and exposes the typed `project_source_reference_from_publication_identity` ACL. The remaining v3 source-persistence gap is narrower: native `save_project` still does not look up the retained identity by an explicit project id and inject the resulting `sourceReference` immediately before serialization, and restart still does not re-admit the app-owned artifact to reconstruct fresh bootstrap/playback authority. #1160 may resolve persisted `selectedPlaybackSource` only after that fresh authority exists. +Resource Admission #866 materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 ordinarily adopted that implementation, exposes the typed `project_source_reference_from_publication_identity` ACL, and now injects the exact retained identity into v3 Save when the mounted local-analysis result supplies its minted project id. + +The principal source-persistence gap has therefore moved to restart. `load_project` validates and returns the persisted v3 `sourceReference`, but it does not yet resolve only the app-owned artifact, re-establish regular/no-link containment, re-check bounded size and SHA-256 plus applicable decode/admission, reconstruct a fresh bootstrap, and retain a fresh runtime identity before returning playable authority. #1160 may resolve persisted `selectedPlaybackSource` only after that fresh authority exists and must fail closed to Full mix when a preferred stem is no longer available. Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery, downgrade/application rollback, restart source re-admission and independent exact-head review evidence remain release gates. From 8f441881f8d3a5b3fce4ab2bcf934f6fe8ef0397 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:14:27 +0900 Subject: [PATCH 347/448] docs(project): correct bridge RED provenance --- docs/traceability/project-v2-ipc-bridge.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 85db55ca3..17f629aea 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -29,7 +29,7 @@ After Resource Admission entered #970 ancestry, a second handoff defect remained - `ecc2904f55516806b51baa4bbafeef9d700b058c` added the renderer bridge RED for all five stable semantics, round-trip load, runtime-authority rejection and unknown preference fields. - `30bfa590df61a2b031076af81010f3e5f31372ea` added the TypeScript Project Persistence anti-corruption boundary; `64613fbb604c4ddc6d156c84bc520dd8d40cef19` wired `saveProjectDocument`/`loadProjectDocument` through the existing Tauri command boundary. - `7f9d118b08038fd5473b71f0a1243136b39e04bc` changed native `save_project` to strict current-document admission and `load_project` to return the typed current document. `327c83f86c1ed213a1f6a58d382715e744ab9831` immediately reverted an unrelated transient score-root edit found during review. -- `3db1096baa52de34baa7fea82b7b09938aa3970a` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. +- `3db1096baa52de34baa7fea4c1638185914d22b7` added the custom-prototype RED; `7cc4869560155039ff1e2e10d171505885dc39e3` restricted admission to ordinary/null-prototype JSON records, and `3f4ce38c2be533a7b8bc90cd67b702d624cd3d1a` closed its edge coverage. - `a71439d82932f671d8079c5f7c78b401679dcb6b` added Proxy/accessor REDs. `bc8e144355353e6311425afe734dfcf8e282ccd5` made exact-key enumeration exception-safe and required own enumerable data properties; `bc7e6c5877da9af6c9a349ea6e6c78c55eecec4e` added nested selection-accessor coverage. - The later v3 source-reference extension preserves the same passive-record boundary: `f54be004887c11cd7a00065b7db86510e5c83ee8` adds the renderer source-reference contract, `04b4a93dbd7ecf5c6d3bdf4434f7908d06ffd73b` closes optional descriptor traps, and `c1cdcd036749a0a9231682db9446e5fbbe410d40` verifies source-reference getters/traps are not executed. - Browser-persistence RED `6eea76fdb138838d61e8af0d23ea69d99012de21` requires Save without a Tauri invoke bridge to reject instead of reporting a success that wrote no bytes. Fix `cb7f4fd956278d1273e6be3f2df367171baadf9e` makes the browser fallback fail closed with `Local project save is not available in browser preview.` while leaving native Tauri persistence unchanged. From f1d307d415787f137660eb982614fa1f9d37f6e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:30:13 +0900 Subject: [PATCH 348/448] test(project): require restart audio byte re-admission --- .../tests/local_audio_restart_readmission.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/desktop/core/tests/local_audio_restart_readmission.rs diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs new file mode 100644 index 000000000..e7027eb11 --- /dev/null +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -0,0 +1,71 @@ +use bandscope_desktop_core::{ + re_admit_local_audio_publication, ProjectSourceReferencePayload, +}; +use std::io::Cursor; + +const WAV_BYTES: &[u8] = &[ + 0x52, 0x49, 0x46, 0x46, 0x2c, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, + 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x40, 0x1f, 0x00, 0x00, + 0x40, 0x1f, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x64, 0x61, 0x74, 0x61, 0x08, 0x00, + 0x00, 0x00, 0x80, 0xa0, 0xc0, 0xe0, 0xff, 0xe0, 0xc0, 0xa0, +]; +const WAV_SHA256: &str = + "6edea6da3400897a1eae8dede07c13843cffd02a91dc3599cd1f542a9a888be5"; + +fn source_reference() -> ProjectSourceReferencePayload { + ProjectSourceReferencePayload { + project_id: "project-600-6".to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: WAV_BYTES.len() as u64, + content_sha256: WAV_SHA256.to_string(), + } +} + +#[test] +fn restart_re_admission_accepts_only_the_exact_persisted_audio_bytes() { + let identity = re_admit_local_audio_publication(&source_reference(), Cursor::new(WAV_BYTES)) + .expect("the exact persisted app-owned WAV should regain native identity"); + + assert_eq!(identity.project_id, "project-600-6"); + assert_eq!(identity.artifact_name, "source.wav"); + assert_eq!(identity.extension, "wav"); + assert_eq!(identity.file_size_bytes, WAV_BYTES.len() as u64); + assert_eq!(identity.content_sha256, WAV_SHA256); +} + +#[test] +fn restart_re_admission_rejects_same_size_audio_mutation() { + let mut mutated = WAV_BYTES.to_vec(); + let last = mutated.len() - 1; + mutated[last] ^= 0x01; + + let error = re_admit_local_audio_publication(&source_reference(), Cursor::new(mutated)) + .expect_err("same-size audio replacement must not regain runtime authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} + +#[test] +fn restart_re_admission_rejects_growth_and_truncation() { + let mut grown = WAV_BYTES.to_vec(); + grown.push(0x00); + let truncated = &WAV_BYTES[..WAV_BYTES.len() - 1]; + + for bytes in [grown.as_slice(), truncated] { + let error = re_admit_local_audio_publication(&source_reference(), Cursor::new(bytes)) + .expect_err("changed byte length must not regain runtime authority"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} + +#[test] +fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity() { + let mut forged = source_reference(); + forged.artifact_name = "../source.wav".to_string(); + + let error = re_admit_local_audio_publication(&forged, Cursor::new(WAV_BYTES)) + .expect_err("typed but forged artifact identity must fail the reverse ACL"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} From 823cd4aea009a3d0904cc9710971c70389dd6ad4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:30:32 +0900 Subject: [PATCH 349/448] fix(project): re-admit persisted audio bytes on restart --- apps/desktop/core/src/source_readmission.rs | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/desktop/core/src/source_readmission.rs diff --git a/apps/desktop/core/src/source_readmission.rs b/apps/desktop/core/src/source_readmission.rs new file mode 100644 index 000000000..815356897 --- /dev/null +++ b/apps/desktop/core/src/source_readmission.rs @@ -0,0 +1,52 @@ +use crate::{ + audio_resource::{verify_local_audio_publication_receipt, LocalAudioCopyReceipt}, + project_format::ProjectSourceReferencePayload, + publication_identity::{build_local_audio_publication_identity, LocalAudioPublicationIdentity}, +}; +use std::io::Read; + +const LOCAL_AUDIO_RE_ADMISSION_ERROR: &str = "Could not prepare the local project workspace."; + +/// Re-establish native content identity for a persisted app-owned full-mix artifact. +/// +/// Security Notes: `ProjectSourceReferencePayload` is durable evidence, not runtime +/// filesystem authority. This reverse ACL validates the reference through the +/// Resource Admission identity builder before reading, then hashes no more than +/// the persisted byte length plus the verifier's one-byte growth probe. Runtime +/// authority is returned only when the opened app-owned stream reproduces both +/// the exact byte count and SHA-256 digest. Paths and playback capabilities are +/// intentionally absent from this boundary; the native adapter remains +/// responsible for deriving and opening only `source.` below the +/// validated BandScope project root. +pub fn re_admit_local_audio_publication( + reference: &ProjectSourceReferencePayload, + reader: R, +) -> Result { + let expected_receipt = LocalAudioCopyReceipt { + file_size_bytes: reference.file_size_bytes, + content_sha256: reference.content_sha256.clone(), + }; + let expected_identity = build_local_audio_publication_identity( + &reference.project_id, + &reference.extension, + &expected_receipt, + ) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + if expected_identity.artifact_name != reference.artifact_name { + return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); + } + + let verified_receipt = verify_local_audio_publication_receipt(reader, &expected_receipt) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + let verified_identity = build_local_audio_publication_identity( + &reference.project_id, + &reference.extension, + &verified_receipt, + ) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + + if verified_identity != expected_identity { + return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); + } + Ok(verified_identity) +} From 54390ce88fa6f082171682dc6d32ac5aa4a8cfe3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:30:42 +0900 Subject: [PATCH 350/448] fix(project): export restart audio re-admission --- apps/desktop/core/src/root.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index b50c56144..3c562ec58 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -16,6 +16,7 @@ mod content_sha256; mod project_format; mod publication_identity; mod score_pdf; +mod source_readmission; pub use audio_resource::{ copy_bounded_local_audio, copy_bounded_local_audio_with_receipt, @@ -35,3 +36,4 @@ pub use publication_identity::{ }; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; +pub use source_readmission::re_admit_local_audio_publication; From 3389786dba2472160c97ea1bed92d4a16015680d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:31:41 +0900 Subject: [PATCH 351/448] refactor(project): keep restart identity verification minimal --- apps/desktop/core/src/source_readmission.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/desktop/core/src/source_readmission.rs b/apps/desktop/core/src/source_readmission.rs index 815356897..821430392 100644 --- a/apps/desktop/core/src/source_readmission.rs +++ b/apps/desktop/core/src/source_readmission.rs @@ -36,17 +36,7 @@ pub fn re_admit_local_audio_publication( return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); } - let verified_receipt = verify_local_audio_publication_receipt(reader, &expected_receipt) + verify_local_audio_publication_receipt(reader, &expected_receipt) .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; - let verified_identity = build_local_audio_publication_identity( - &reference.project_id, - &reference.extension, - &verified_receipt, - ) - .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; - - if verified_identity != expected_identity { - return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); - } - Ok(verified_identity) + Ok(expected_identity) } From 20ded47d8617b7b2312588e82fe75cb19f5adfe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:31:59 +0900 Subject: [PATCH 352/448] test(project): cover malformed restart source evidence --- .../core/tests/local_audio_restart_readmission.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs index e7027eb11..69bbdea88 100644 --- a/apps/desktop/core/tests/local_audio_restart_readmission.rs +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -69,3 +69,15 @@ fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity() { assert_eq!(error, "Could not prepare the local project workspace."); } + +#[test] +fn restart_re_admission_rejects_malformed_durable_identity_before_reading() { + for mut malformed in [source_reference(), source_reference()] { + if malformed.project_id == "project-600-6" { + malformed.project_id = "../project-600-6".to_string(); + } + let error = re_admit_local_audio_publication(&malformed, Cursor::new(WAV_BYTES)) + .expect_err("malformed persisted identity must fail before becoming runtime authority"); + assert_eq!(error, "Could not prepare the local project workspace."); + } +} From cbfc23f793917189f1893523d6bf469ea60cf6c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:32:21 +0900 Subject: [PATCH 353/448] test(project): distinguish malformed restart identity cases --- .../core/tests/local_audio_restart_readmission.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs index 69bbdea88..1bdca8e42 100644 --- a/apps/desktop/core/tests/local_audio_restart_readmission.rs +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -72,10 +72,13 @@ fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity() { #[test] fn restart_re_admission_rejects_malformed_durable_identity_before_reading() { - for mut malformed in [source_reference(), source_reference()] { - if malformed.project_id == "project-600-6" { - malformed.project_id = "../project-600-6".to_string(); - } + let mut invalid_project = source_reference(); + invalid_project.project_id = "../project-600-6".to_string(); + let mut invalid_extension = source_reference(); + invalid_extension.extension = "WAV".to_string(); + invalid_extension.artifact_name = "source.WAV".to_string(); + + for malformed in [invalid_project, invalid_extension] { let error = re_admit_local_audio_publication(&malformed, Cursor::new(WAV_BYTES)) .expect_err("malformed persisted identity must fail before becoming runtime authority"); assert_eq!(error, "Could not prepare the local project workspace."); From 8b84312afb4ca9011ac6bc2e9bb33c6a372fee18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:35:46 +0900 Subject: [PATCH 354/448] docs(project): trace restart source re-admission boundary --- .../project-v3-source-restart-readmission.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/traceability/project-v3-source-restart-readmission.md diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md new file mode 100644 index 000000000..9bbabef9a --- /dev/null +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -0,0 +1,96 @@ +# Project v3 source restart re-admission + +## Problem + +Project format v3 can persist a path-free `sourceReference` after Resource Admission has materialized and verified the app-owned full-mix artifact. On restart, however, persisted evidence must not become filesystem or playback authority merely because its JSON shape is valid. A replaced, truncated, extended, or same-size-mutated `source.` must not silently regain rehearsal authority. + +The preceding Save path already keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. The missing reverse boundary is to re-establish native content identity from those persisted claims only after an app-owned stream reproduces the exact bytes described by the reference. + +## Constraints + +- Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. +- Persisted JSON is evidence, not permission to open a path. +- The reverse boundary must consume an already-authorized `Read`, not a user-controlled pathname. +- Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. +- The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. +- Historical projects without `sourceReference` remain without source authority; migration does not invent evidence. +- A content-identity match alone does not prove filesystem containment or audio decodability. The native reopen adapter must separately establish regular/no-link/reparse descriptor authority and applicable decode/admission before fresh runtime authority is issued. + +## RED evidence + +`f1d307d415787f137660eb982614fa1f9d37f6e7` adds the executable core contract `local_audio_restart_readmission.rs`. The predecessor has no `re_admit_local_audio_publication` API, so the contract cannot compile there. + +The contract uses a tiny deterministic PCM WAV byte sequence solely as a unit fixture. It requires exact bytes to regain native publication identity and rejects: + +- a one-byte mutation that preserves the total file size; +- appended bytes; +- truncation; +- a forged `../source.wav` artifact name; +- malformed project-id and non-canonical extension evidence. + +This fixture is not scientific or release acceptance evidence. Rights-cleared real decoded audio remains required for production audio acceptance. + +## Selected design + +`823cd4aea009a3d0904cc9710971c70389dd6ad4` adds `re_admit_local_audio_publication(reference, reader)` in the GUI-independent desktop core. `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exports it from the single crate root. `3389786dba2472160c97ea1bed92d4a16015680d` removes redundant identity reconstruction; current edge coverage is completed through `cbfc23f793917189f1893523d6bf469ea60cf6c1`. + +The reverse ACL performs two distinct checks: + +1. Reconstruct expected Resource Admission identity from the durable project id, admitted extension, byte length, and SHA-256. The fixed artifact name derived by Resource Admission must exactly match persisted `artifactName`. +2. Pass the already-opened stream to the canonical bounded publication-receipt verifier. The stream must reproduce the exact byte count and SHA-256; growth, truncation, read failure, or same-size content mismatch fails closed. + +The result is a path-free `LocalAudioPublicationIdentity`. It is not a playback URL, local path, or file handle and cannot by itself authorize access to any filesystem object. + +## Rejected alternatives + +**Trust the persisted digest after schema validation.** Rejected because a syntactically valid digest only states what bytes are expected; it does not prove the current app-owned artifact still contains those bytes. + +**Re-open a persisted path in the core function.** Rejected because v3 intentionally carries no path, and accepting one would collapse Project Persistence evidence into Resource Admission filesystem authority. + +**Compare only file size.** Rejected because same-size replacement is a realistic integrity failure and is explicitly covered by the RED contract. + +**Hash until EOF without the persisted bound.** Rejected because a corrupted or replaced object could force unnecessary I/O before mismatch is known. The existing verifier reads the expected bytes and one growth probe. + +**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish final-component no-link/reparse containment, descriptor-bound location authority, decoder acceptance, or current playable-stem availability. + +## Security Notes + +### Attack surface and trust boundary + +The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. Native code must derive the only permitted artifact name from the validated admitted extension and must open it under BandScope's app-owned project namespace before calling the reverse ACL. + +### Allowlist and validation + +The existing Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The reverse ACL reuses those canonical rules rather than duplicating them in Project Persistence. + +### Safe failure + +Malformed durable evidence, forged artifact names, read errors, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission returns native source identity or playback capability. The native reopen adapter must additionally fail closed on missing/non-regular/linked/reparsed artifacts or decode/admission failure. + +### Logging and privacy + +The reverse ACL does not receive or log the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure must not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. + +### Test points + +`apps/desktop/core/tests/local_audio_restart_readmission.rs` executes exact-byte success plus same-size mutation, growth, truncation, forged artifact, malformed project-id, and non-canonical extension failures. Existing Resource Admission tests remain the canonical coverage for bounded copying, publication receipt generation, read/write failure separation, digest known-answer vectors, and maximum-size enforcement. + +### Remaining risk + +The production Tauri `load_project` adapter is not yet wired to this reverse ACL. Before v3 source persistence is release-ready it must derive only the app-owned artifact, acquire a regular no-link/reparse descriptor with appropriate containment/identity checks, call the reverse ACL, run applicable decode/admission, restore fresh bootstrap/native identity state, and only then allow Active Player to resolve persisted source intent against current availability. Descriptor-bound parent authority, restart fault injection, and rights-cleared Windows/macOS real-audio acceptance remain separate required evidence. + +## Standards traceability + +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise FIPS 180-4, including removing SHA-1 and updating guidance, but the replacement standard has not superseded FIPS 180-4 as of this decision record. This use of SHA-256 is an integrity equality check and does not claim FIPS 140 module validation or CAVP validation. + +The implementation also follows the released NIST SSDF 1.1 principle of addressing root causes through explicit development and verification controls. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft rather than the released normative baseline used here. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd From dbe4959ea311eda22b59af2c105c6cd0cdc52f4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:37:29 +0900 Subject: [PATCH 355/448] docs(project): complete restart Security Notes contract --- .../traceability/project-v3-source-restart-readmission.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md index 9bbabef9a..4517dd11c 100644 --- a/docs/traceability/project-v3-source-restart-readmission.md +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -63,6 +63,10 @@ The `.bscope` document and renderer-visible data are untrusted. `sourceReference The existing Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The reverse ACL reuses those canonical rules rather than duplicating them in Project Persistence. +### Mitigations + +The content boundary is split deliberately: Project Persistence supplies only path-free durable evidence; the native filesystem adapter must establish containment and an opened regular descriptor; Resource Admission then verifies the opened bytes against the persisted bounded receipt. The fixed artifact-name derivation, one-byte growth probe, exact SHA-256 comparison, and absence of a pathname parameter prevent the reverse ACL from turning project JSON into ambient filesystem authority. + ### Safe failure Malformed durable evidence, forged artifact names, read errors, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission returns native source identity or playback capability. The native reopen adapter must additionally fail closed on missing/non-regular/linked/reparsed artifacts or decode/admission failure. @@ -75,6 +79,10 @@ The reverse ACL does not receive or log the original user-selected path. SHA-256 `apps/desktop/core/tests/local_audio_restart_readmission.rs` executes exact-byte success plus same-size mutation, growth, truncation, forged artifact, malformed project-id, and non-canonical extension failures. Existing Resource Admission tests remain the canonical coverage for bounded copying, publication receipt generation, read/write failure separation, digest known-answer vectors, and maximum-size enforcement. +### Realistic threats + +Relevant threats are local project corruption after a reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, and attempts to smuggle traversal-like artifact names through typed but untrusted durable data. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. + ### Remaining risk The production Tauri `load_project` adapter is not yet wired to this reverse ACL. Before v3 source persistence is release-ready it must derive only the app-owned artifact, acquire a regular no-link/reparse descriptor with appropriate containment/identity checks, call the reverse ACL, run applicable decode/admission, restore fresh bootstrap/native identity state, and only then allow Active Player to resolve persisted source intent against current availability. Descriptor-bound parent authority, restart fault injection, and rights-cleared Windows/macOS real-audio acceptance remain separate required evidence. From e1158119a73a357956d042bb3d0bd977ababef8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:39:24 +0900 Subject: [PATCH 356/448] test(project): prove malformed restart evidence is not read --- .../tests/local_audio_restart_readmission.rs | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs index 1bdca8e42..1f36d713e 100644 --- a/apps/desktop/core/tests/local_audio_restart_readmission.rs +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -1,7 +1,7 @@ use bandscope_desktop_core::{ re_admit_local_audio_publication, ProjectSourceReferencePayload, }; -use std::io::Cursor; +use std::io::{Cursor, Error, ErrorKind, Read, Result as IoResult}; const WAV_BYTES: &[u8] = &[ 0x52, 0x49, 0x46, 0x46, 0x2c, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, @@ -12,6 +12,22 @@ const WAV_BYTES: &[u8] = &[ const WAV_SHA256: &str = "6edea6da3400897a1eae8dede07c13843cffd02a91dc3599cd1f542a9a888be5"; +struct RejectRead; + +impl Read for RejectRead { + fn read(&mut self, _buf: &mut [u8]) -> IoResult { + panic!("malformed durable evidence must be rejected before reading the artifact"); + } +} + +struct FailingReader; + +impl Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> IoResult { + Err(Error::new(ErrorKind::PermissionDenied, "private OS detail")) + } +} + fn source_reference() -> ProjectSourceReferencePayload { ProjectSourceReferencePayload { project_id: "project-600-6".to_string(), @@ -60,11 +76,11 @@ fn restart_re_admission_rejects_growth_and_truncation() { } #[test] -fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity() { +fn restart_re_admission_revalidates_fixed_app_owned_artifact_identity_before_reading() { let mut forged = source_reference(); forged.artifact_name = "../source.wav".to_string(); - let error = re_admit_local_audio_publication(&forged, Cursor::new(WAV_BYTES)) + let error = re_admit_local_audio_publication(&forged, RejectRead) .expect_err("typed but forged artifact identity must fail the reverse ACL"); assert_eq!(error, "Could not prepare the local project workspace."); @@ -79,8 +95,17 @@ fn restart_re_admission_rejects_malformed_durable_identity_before_reading() { invalid_extension.artifact_name = "source.WAV".to_string(); for malformed in [invalid_project, invalid_extension] { - let error = re_admit_local_audio_publication(&malformed, Cursor::new(WAV_BYTES)) + let error = re_admit_local_audio_publication(&malformed, RejectRead) .expect_err("malformed persisted identity must fail before becoming runtime authority"); assert_eq!(error, "Could not prepare the local project workspace."); } } + +#[test] +fn restart_re_admission_does_not_expose_reader_failures() { + let error = re_admit_local_audio_publication(&source_reference(), FailingReader) + .expect_err("a failed app-owned read must not regain runtime authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); + assert!(!error.contains("private OS detail")); +} From 66ed5ec328d498bae59af2814b20a16884f30bae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:00:46 +0900 Subject: [PATCH 357/448] test(project): require safe app-owned source reopen authority --- .../project_persistence_open_authority.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index d2cabe3ae..574a1fd40 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -32,3 +32,92 @@ fn unix_project_opener_refuses_symlink_at_handle_acquisition() { ); fs::remove_dir_all(root).expect("test directory should be removable"); } + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn app_owned_source_opener_returns_the_exact_regular_artifact() { + use std::{ + io::Read, + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-authority-{}-{nonce}", + std::process::id() + )); + let project_root = root.join("project-1-1"); + fs::create_dir_all(&project_root).expect("project root should be created"); + let source_path = project_root.join("source.wav"); + let source_bytes = b"RIFF-safe-reopen-fixture"; + fs::write(&source_path, source_bytes).expect("source fixture should be written"); + + let mut opened = project_persistence::open_app_owned_source_file(&project_root, "source.wav") + .expect("the exact regular app-owned source should be opened"); + let mut observed = Vec::new(); + opened + .read_to_end(&mut observed) + .expect("opened source should remain readable"); + + assert_eq!(observed, source_bytes); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn app_owned_source_opener_rejects_artifact_traversal_before_reading() { + use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-traversal-{}-{nonce}", + std::process::id() + )); + let project_root = root.join("project-1-1"); + fs::create_dir_all(&project_root).expect("project root should be created"); + fs::write(root.join("external.wav"), b"outside-project") + .expect("external fixture should be written"); + + let error = project_persistence::open_app_owned_source_file(&project_root, "../external.wav") + .expect_err("persisted artifact evidence must not create path traversal authority"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn app_owned_source_opener_refuses_a_symlink_artifact() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-open-symlink-{}-{nonce}", + std::process::id() + )); + let project_root = root.join("project-1-1"); + fs::create_dir_all(&project_root).expect("project root should be created"); + let external = root.join("external.wav"); + let source_path = project_root.join("source.wav"); + fs::write(&external, b"outside-project").expect("external fixture should be written"); + symlink(&external, &source_path).expect("source symlink should be created"); + + let error = project_persistence::open_app_owned_source_file(&project_root, "source.wav") + .expect_err("app-owned source authority must not follow a symlink artifact"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(root).expect("test directory should be removable"); +} From f36996f251e0fdbe300df6f525d2b64fff785f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:03:54 +0900 Subject: [PATCH 358/448] fix(project): bind restart re-admission to native opener --- apps/desktop/core/src/source_readmission.rs | 98 ++++++++++++++++++--- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/apps/desktop/core/src/source_readmission.rs b/apps/desktop/core/src/source_readmission.rs index 821430392..7e796f0f7 100644 --- a/apps/desktop/core/src/source_readmission.rs +++ b/apps/desktop/core/src/source_readmission.rs @@ -3,24 +3,29 @@ use crate::{ project_format::ProjectSourceReferencePayload, publication_identity::{build_local_audio_publication_identity, LocalAudioPublicationIdentity}, }; -use std::io::Read; +use std::{ + ffi::OsStr, + io::Read, + path::{Path, PathBuf}, +}; const LOCAL_AUDIO_RE_ADMISSION_ERROR: &str = "Could not prepare the local project workspace."; -/// Re-establish native content identity for a persisted app-owned full-mix artifact. +/// Fresh runtime evidence recovered from one persisted app-owned audio publication. /// -/// Security Notes: `ProjectSourceReferencePayload` is durable evidence, not runtime -/// filesystem authority. This reverse ACL validates the reference through the -/// Resource Admission identity builder before reading, then hashes no more than -/// the persisted byte length plus the verifier's one-byte growth probe. Runtime -/// authority is returned only when the opened app-owned stream reproduces both -/// the exact byte count and SHA-256 digest. Paths and playback capabilities are -/// intentionally absent from this boundary; the native adapter remains -/// responsible for deriving and opening only `source.` below the -/// validated BandScope project root. -pub fn re_admit_local_audio_publication( +/// `source_path` is transient native authority only. It is derived from the +/// validated BandScope project root plus the fixed Resource Admission artifact +/// name and must never be serialized back into a `.bscope` document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReAdmittedLocalAudioPublication { + /// Exact app-owned source path that the native opener authorized. + pub source_path: PathBuf, + /// Re-established path-free content identity for native state. + pub identity: LocalAudioPublicationIdentity, +} + +fn expected_publication_identity( reference: &ProjectSourceReferencePayload, - reader: R, ) -> Result { let expected_receipt = LocalAudioCopyReceipt { file_size_bytes: reference.file_size_bytes, @@ -35,8 +40,73 @@ pub fn re_admit_local_audio_publication( if expected_identity.artifact_name != reference.artifact_name { return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); } + Ok(expected_identity) +} +fn verify_re_admitted_publication( + identity: LocalAudioPublicationIdentity, + reader: R, +) -> Result { + let expected_receipt = LocalAudioCopyReceipt { + file_size_bytes: identity.file_size_bytes, + content_sha256: identity.content_sha256.clone(), + }; verify_local_audio_publication_receipt(reader, &expected_receipt) .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; - Ok(expected_identity) + Ok(identity) +} + +/// Re-establish native content identity for a persisted app-owned full-mix artifact. +/// +/// Security Notes: `ProjectSourceReferencePayload` is durable evidence, not runtime +/// filesystem authority. This reverse ACL validates the reference through the +/// Resource Admission identity builder before reading, then hashes no more than +/// the persisted byte length plus the verifier's one-byte growth probe. Runtime +/// authority is returned only when the opened app-owned stream reproduces both +/// the exact byte count and SHA-256 digest. Paths and playback capabilities are +/// intentionally absent from this boundary; the native adapter remains +/// responsible for deriving and opening only `source.` below the +/// validated BandScope project root. +pub fn re_admit_local_audio_publication( + reference: &ProjectSourceReferencePayload, + reader: R, +) -> Result { + let expected_identity = expected_publication_identity(reference)?; + verify_re_admitted_publication(expected_identity, reader) +} + +/// Resolve and re-admit one persisted source through a native no-follow opener. +/// +/// Security Notes: durable evidence is validated before the opener is invoked, +/// so a forged `artifactName`, extension, digest, size, or project id cannot be +/// turned into a filesystem lookup. The supplied project root must end in the +/// same BandScope project id, and the path is derived from the validated fixed +/// `source.` artifact name rather than from untrusted path text. +/// `open_file` remains an injected native authority so platform code can enforce +/// O_NOFOLLOW/reparse-point and file-identity rules without duplicating those +/// primitives in this bounded-context ACL. Parent-directory descriptor binding +/// remains the native adapter's responsibility. +pub fn re_admit_local_audio_publication_from_project_root( + project_root: &Path, + reference: &ProjectSourceReferencePayload, + open_file: F, +) -> Result +where + R: Read, + F: FnOnce(&Path) -> std::io::Result, +{ + let expected_identity = expected_publication_identity(reference)?; + if project_root.file_name() != Some(OsStr::new(&expected_identity.project_id)) { + return Err(LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string()); + } + + let source_path = project_root.join(&expected_identity.artifact_name); + let reader = open_file(&source_path) + .map_err(|_| LOCAL_AUDIO_RE_ADMISSION_ERROR.to_string())?; + let identity = verify_re_admitted_publication(expected_identity, reader)?; + + Ok(ReAdmittedLocalAudioPublication { + source_path, + identity, + }) } From c7e112fa4da9f28ad886cdd21afa83ac6a7a3846 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:04:02 +0900 Subject: [PATCH 359/448] feat(project): export native reopen ACL --- apps/desktop/core/src/root.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 3c562ec58..ce70d9b66 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -36,4 +36,7 @@ pub use publication_identity::{ }; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; -pub use source_readmission::re_admit_local_audio_publication; +pub use source_readmission::{ + re_admit_local_audio_publication, re_admit_local_audio_publication_from_project_root, + ReAdmittedLocalAudioPublication, +}; From b80dea3c2c0f2ca1658a70504419b7054cf50c93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:04:33 +0900 Subject: [PATCH 360/448] test(project): exercise restart ACL through native opener --- .../project_persistence_open_authority.rs | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index 574a1fd40..feeb755c1 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -1,6 +1,23 @@ #[path = "../src/project_persistence.rs"] mod project_persistence; +use bandscope_desktop_core::{ + re_admit_local_audio_publication_from_project_root, sha256_hex_reader, + ProjectSourceReferencePayload, +}; +use std::io::Cursor; + +fn source_reference(project_id: &str, bytes: &[u8]) -> ProjectSourceReferencePayload { + ProjectSourceReferencePayload { + project_id: project_id.to_string(), + artifact_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: bytes.len() as u64, + content_sha256: sha256_hex_reader(Cursor::new(bytes)) + .expect("test fixture digest should be computable"), + } +} + #[cfg(unix)] #[test] fn unix_project_opener_refuses_symlink_at_handle_acquisition() { @@ -35,9 +52,8 @@ fn unix_project_opener_refuses_symlink_at_handle_acquisition() { #[cfg(any(target_os = "linux", target_os = "macos", windows))] #[test] -fn app_owned_source_opener_returns_the_exact_regular_artifact() { +fn restart_adapter_reopens_the_exact_regular_app_owned_source() { use std::{ - io::Read, fs, time::{SystemTime, UNIX_EPOCH}, }; @@ -50,25 +66,29 @@ fn app_owned_source_opener_returns_the_exact_regular_artifact() { "bandscope-source-open-authority-{}-{nonce}", std::process::id() )); - let project_root = root.join("project-1-1"); + let project_id = "project-1-1"; + let project_root = root.join(project_id); fs::create_dir_all(&project_root).expect("project root should be created"); - let source_path = project_root.join("source.wav"); let source_bytes = b"RIFF-safe-reopen-fixture"; - fs::write(&source_path, source_bytes).expect("source fixture should be written"); - - let mut opened = project_persistence::open_app_owned_source_file(&project_root, "source.wav") - .expect("the exact regular app-owned source should be opened"); - let mut observed = Vec::new(); - opened - .read_to_end(&mut observed) - .expect("opened source should remain readable"); - - assert_eq!(observed, source_bytes); + fs::write(project_root.join("source.wav"), source_bytes) + .expect("source fixture should be written"); + let reference = source_reference(project_id, source_bytes); + + let reopened = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + project_persistence::open_project_file, + ) + .expect("the exact regular app-owned source should regain native identity"); + + assert_eq!(reopened.source_path, project_root.join("source.wav")); + assert_eq!(reopened.identity.project_id, project_id); + assert_eq!(reopened.identity.content_sha256, reference.content_sha256); fs::remove_dir_all(root).expect("test directory should be removable"); } #[test] -fn app_owned_source_opener_rejects_artifact_traversal_before_reading() { +fn restart_adapter_rejects_artifact_traversal_before_opening() { use std::{fs, time::{SystemTime, UNIX_EPOCH}}; let nonce = SystemTime::now() @@ -79,13 +99,20 @@ fn app_owned_source_opener_rejects_artifact_traversal_before_reading() { "bandscope-source-open-traversal-{}-{nonce}", std::process::id() )); - let project_root = root.join("project-1-1"); + let project_id = "project-1-1"; + let project_root = root.join(project_id); fs::create_dir_all(&project_root).expect("project root should be created"); - fs::write(root.join("external.wav"), b"outside-project") - .expect("external fixture should be written"); - - let error = project_persistence::open_app_owned_source_file(&project_root, "../external.wav") - .expect_err("persisted artifact evidence must not create path traversal authority"); + let mut reference = source_reference(project_id, b"outside-project"); + reference.artifact_name = "../external.wav".to_string(); + + let error = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + |_path| -> std::io::Result { + panic!("forged durable evidence must fail before filesystem authority is requested") + }, + ) + .expect_err("persisted artifact evidence must not create path traversal authority"); assert_eq!(error, "Could not prepare the local project workspace."); fs::remove_dir_all(root).expect("test directory should be removable"); @@ -93,7 +120,7 @@ fn app_owned_source_opener_rejects_artifact_traversal_before_reading() { #[cfg(unix)] #[test] -fn app_owned_source_opener_refuses_a_symlink_artifact() { +fn restart_adapter_refuses_a_symlink_source_artifact() { use std::{ fs, os::unix::fs::symlink, @@ -108,15 +135,22 @@ fn app_owned_source_opener_refuses_a_symlink_artifact() { "bandscope-source-open-symlink-{}-{nonce}", std::process::id() )); - let project_root = root.join("project-1-1"); + let project_id = "project-1-1"; + let project_root = root.join(project_id); fs::create_dir_all(&project_root).expect("project root should be created"); + let external_bytes = b"outside-project"; let external = root.join("external.wav"); let source_path = project_root.join("source.wav"); - fs::write(&external, b"outside-project").expect("external fixture should be written"); + fs::write(&external, external_bytes).expect("external fixture should be written"); symlink(&external, &source_path).expect("source symlink should be created"); - - let error = project_persistence::open_app_owned_source_file(&project_root, "source.wav") - .expect_err("app-owned source authority must not follow a symlink artifact"); + let reference = source_reference(project_id, external_bytes); + + let error = re_admit_local_audio_publication_from_project_root( + &project_root, + &reference, + project_persistence::open_project_file, + ) + .expect_err("app-owned source authority must not follow a symlink artifact"); assert_eq!(error, "Could not prepare the local project workspace."); fs::remove_dir_all(root).expect("test directory should be removable"); From b975843d57a6642fe54c36e242693473f8d25852 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:05:14 +0900 Subject: [PATCH 361/448] test(project): cover project-root restart binding --- .../tests/local_audio_restart_readmission.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/tests/local_audio_restart_readmission.rs b/apps/desktop/core/tests/local_audio_restart_readmission.rs index 1f36d713e..fb1e76e77 100644 --- a/apps/desktop/core/tests/local_audio_restart_readmission.rs +++ b/apps/desktop/core/tests/local_audio_restart_readmission.rs @@ -1,7 +1,11 @@ use bandscope_desktop_core::{ - re_admit_local_audio_publication, ProjectSourceReferencePayload, + re_admit_local_audio_publication, re_admit_local_audio_publication_from_project_root, + ProjectSourceReferencePayload, +}; +use std::{ + io::{Cursor, Error, ErrorKind, Read, Result as IoResult}, + path::Path, }; -use std::io::{Cursor, Error, ErrorKind, Read, Result as IoResult}; const WAV_BYTES: &[u8] = &[ 0x52, 0x49, 0x46, 0x46, 0x2c, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, 0x6d, @@ -109,3 +113,34 @@ fn restart_re_admission_does_not_expose_reader_failures() { assert_eq!(error, "Could not prepare the local project workspace."); assert!(!error.contains("private OS detail")); } + +#[test] +fn project_root_adapter_derives_only_the_validated_fixed_artifact_path() { + let root = Path::new("/trusted/app/project-600-6"); + let reopened = re_admit_local_audio_publication_from_project_root( + root, + &source_reference(), + |path| { + assert_eq!(path, root.join("source.wav")); + Ok(Cursor::new(WAV_BYTES)) + }, + ) + .expect("validated durable evidence should derive one fixed app-owned artifact path"); + + assert_eq!(reopened.source_path, root.join("source.wav")); + assert_eq!(reopened.identity.project_id, "project-600-6"); +} + +#[test] +fn project_root_adapter_rejects_cross_project_binding_before_opening() { + let error = re_admit_local_audio_publication_from_project_root( + Path::new("/trusted/app/project-700-7"), + &source_reference(), + |_path| -> IoResult> { + panic!("a mismatched project root must fail before filesystem authority is requested") + }, + ) + .expect_err("persisted evidence must remain bound to its exact project aggregate"); + + assert_eq!(error, "Could not prepare the local project workspace."); +} From 909d54f64889b977dc1b7e7eba10999f503005a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:05:35 +0900 Subject: [PATCH 362/448] style(project): format restart opener coverage --- .../src-tauri/tests/project_persistence_open_authority.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index feeb755c1..81a458a6b 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -89,7 +89,10 @@ fn restart_adapter_reopens_the_exact_regular_app_owned_source() { #[test] fn restart_adapter_rejects_artifact_traversal_before_opening() { - use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) From 6801d95ecd02012317d709a5e55e5ea3a1339f6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:06:26 +0900 Subject: [PATCH 363/448] docs(project): trace native restart opener boundary --- .../project-v3-source-restart-readmission.md | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md index 4517dd11c..417bdeddb 100644 --- a/docs/traceability/project-v3-source-restart-readmission.md +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -4,94 +4,91 @@ Project format v3 can persist a path-free `sourceReference` after Resource Admission has materialized and verified the app-owned full-mix artifact. On restart, however, persisted evidence must not become filesystem or playback authority merely because its JSON shape is valid. A replaced, truncated, extended, or same-size-mutated `source.` must not silently regain rehearsal authority. -The preceding Save path already keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. The missing reverse boundary is to re-establish native content identity from those persisted claims only after an app-owned stream reproduces the exact bytes described by the reference. +The Save path keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. Restart therefore needs two distinct steps: validate that durable evidence before any filesystem lookup is attempted, then re-establish native content identity only from an app-owned descriptor whose bytes reproduce the exact persisted receipt. ## Constraints - Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. - Persisted JSON is evidence, not permission to open a path. -- The reverse boundary must consume an already-authorized `Read`, not a user-controlled pathname. +- Durable fields must be validated before an opener receives any derived artifact path. +- The project root must remain bound to the same BandScope project aggregate; cross-project root substitution fails closed. +- The final artifact descriptor must come from the native platform opener so O_NOFOLLOW/reparse-point and file-identity primitives are not copied into the Project Persistence ACL. - Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. - The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. - Historical projects without `sourceReference` remain without source authority; migration does not invent evidence. -- A content-identity match alone does not prove filesystem containment or audio decodability. The native reopen adapter must separately establish regular/no-link/reparse descriptor authority and applicable decode/admission before fresh runtime authority is issued. +- A content-identity match alone does not prove descriptor-bound parent-directory containment or audio decodability. Those remain explicit native reopen responsibilities before fresh playback authority is issued. ## RED evidence -`f1d307d415787f137660eb982614fa1f9d37f6e7` adds the executable core contract `local_audio_restart_readmission.rs`. The predecessor has no `re_admit_local_audio_publication` API, so the contract cannot compile there. +`f1d307d415787f137660eb982614fa1f9d37f6e7` introduced the executable core content-identity contract. It requires exact persisted WAV bytes to regain native publication identity and rejects same-size mutation, growth, truncation, forged artifact identity, malformed project id, and non-canonical extension evidence. -The contract uses a tiny deterministic PCM WAV byte sequence solely as a unit fixture. It requires exact bytes to regain native publication identity and rejects: +The later native-opener RED `66ed5ec328d498bae59af2814b20a16884f30bae` required restart code to stop at the canonical no-follow opener boundary rather than reconstructing an ambient pathname. That first contract deliberately could not compile on its predecessor because no project-root re-admission adapter existed. During the fix the responsibility was placed in the GUI-independent Project Persistence/Resource Admission ACL rather than duplicating platform open primitives in Tauri. -- a one-byte mutation that preserves the total file size; -- appended bytes; -- truncation; -- a forged `../source.wav` artifact name; -- malformed project-id and non-canonical extension evidence. - -This fixture is not scientific or release acceptance evidence. Rights-cleared real decoded audio remains required for production audio acceptance. +The deterministic PCM WAV bytes used by the core contract are unit fixtures only. The opener integration fixture validates filesystem authority composition, not MIR or decoder quality. Neither is production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. ## Selected design -`823cd4aea009a3d0904cc9710971c70389dd6ad4` adds `re_admit_local_audio_publication(reference, reader)` in the GUI-independent desktop core. `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exports it from the single crate root. `3389786dba2472160c97ea1bed92d4a16015680d` removes redundant identity reconstruction; current edge coverage is completed through `cbfc23f793917189f1893523d6bf469ea60cf6c1`. +`823cd4aea009a3d0904cc9710971c70389dd6ad4` added `re_admit_local_audio_publication(reference, reader)`, with `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exporting the reverse content ACL. Current predecessor hardening through `e1158119a73a357956d042bb3d0bd977ababef8d` proves malformed evidence is rejected before reading and that native read failures collapse to the bounded workspace diagnosis. -The reverse ACL performs two distinct checks: +`f36996f251e0fdbe300df6f525d2b64fff785f3a` adds `re_admit_local_audio_publication_from_project_root(project_root, reference, open_file)` and the transient `ReAdmittedLocalAudioPublication` value object. `c7e112fa4da9f28ad886cdd21afa83ac6a7a3846` exports that ACL from the canonical desktop-core root. The adapter validates the durable reference first, requires the supplied project root basename to match the same `projectId`, derives the lookup only from the validated fixed `source.` identity, and then asks the injected native opener for a descriptor. Only that opened stream is hashed and compared with the persisted bounded receipt. -1. Reconstruct expected Resource Admission identity from the durable project id, admitted extension, byte length, and SHA-256. The fixed artifact name derived by Resource Admission must exactly match persisted `artifactName`. -2. Pass the already-opened stream to the canonical bounded publication-receipt verifier. The stream must reproduce the exact byte count and SHA-256; growth, truncation, read failure, or same-size content mismatch fails closed. +The native integration coverage finalized through `909d54f64889b977dc1b7e7eba10999f503005a9` composes this ACL with the existing `project_persistence::open_project_file` authority. It verifies an exact regular app-owned source can be re-admitted, traversal-like durable artifact evidence is rejected before an opener is invoked, and a Unix symlink at the final `source.wav` component is refused by no-follow handle acquisition. Core coverage in `b975843d57a6642fe54c36e242693473f8d25852` also proves a project-root mismatch fails before filesystem authority is requested. -The result is a path-free `LocalAudioPublicationIdentity`. It is not a playback URL, local path, or file handle and cannot by itself authorize access to any filesystem object. +The resulting runtime value contains a transient app-owned `source_path` plus the path-free `LocalAudioPublicationIdentity`. The path exists only to let native runtime code rebuild bootstrap/decoder authority; it is not serializable project truth and must never be copied back into `sourceReference`. ## Rejected alternatives **Trust the persisted digest after schema validation.** Rejected because a syntactically valid digest only states what bytes are expected; it does not prove the current app-owned artifact still contains those bytes. -**Re-open a persisted path in the core function.** Rejected because v3 intentionally carries no path, and accepting one would collapse Project Persistence evidence into Resource Admission filesystem authority. +**Accept `artifactName` as a pathname.** Rejected because typed durable data is still untrusted. The adapter first reconstructs the canonical Resource Admission identity and derives the fixed artifact name from the admitted extension; forged path-like text fails before the opener is invoked. + +**Copy O_NOFOLLOW/reparse-point logic into the reverse ACL.** Rejected because `project_persistence::open_project_file` already owns the supported-platform final-component handle primitive and native file-identity checks. The reverse ACL injects that authority instead of creating a second security implementation. -**Compare only file size.** Rejected because same-size replacement is a realistic integrity failure and is explicitly covered by the RED contract. +**Compare only file size.** Rejected because same-size replacement is a realistic integrity failure and is explicitly covered by the executable contract. **Hash until EOF without the persisted bound.** Rejected because a corrupted or replaced object could force unnecessary I/O before mismatch is known. The existing verifier reads the expected bytes and one growth probe. -**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish final-component no-link/reparse containment, descriptor-bound location authority, decoder acceptance, or current playable-stem availability. +**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish descriptor-bound parent location authority, decoder acceptance, or current playable-stem availability. ## Security Notes ### Attack surface and trust boundary -The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. Native code must derive the only permitted artifact name from the validated admitted extension and must open it under BandScope's app-owned project namespace before calling the reverse ACL. +The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri supplies the app-local project root; the ACL requires that root to remain bound to the same BandScope project id and derives only the canonical app-owned source artifact below it. ### Allowlist and validation -The existing Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The reverse ACL reuses those canonical rules rather than duplicating them in Project Persistence. +The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. ### Mitigations -The content boundary is split deliberately: Project Persistence supplies only path-free durable evidence; the native filesystem adapter must establish containment and an opened regular descriptor; Resource Admission then verifies the opened bytes against the persisted bounded receipt. The fixed artifact-name derivation, one-byte growth probe, exact SHA-256 comparison, and absence of a pathname parameter prevent the reverse ACL from turning project JSON into ambient filesystem authority. +Project Persistence supplies path-free durable evidence; the project-root ACL validates that evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. This sequencing prevents malformed durable data from reaching filesystem lookup and keeps platform security primitives single-owned. ### Safe failure -Malformed durable evidence, forged artifact names, read errors, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission returns native source identity or playback capability. The native reopen adapter must additionally fail closed on missing/non-regular/linked/reparsed artifacts or decode/admission failure. +Malformed durable evidence, forged artifact names, cross-project root substitution, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission returns native source identity or playback capability. ### Logging and privacy -The reverse ACL does not receive or log the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure must not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. +The reverse ACL never receives the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure must not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. ### Test points -`apps/desktop/core/tests/local_audio_restart_readmission.rs` executes exact-byte success plus same-size mutation, growth, truncation, forged artifact, malformed project-id, and non-canonical extension failures. Existing Resource Admission tests remain the canonical coverage for bounded copying, publication receipt generation, read/write failure separation, digest known-answer vectors, and maximum-size enforcement. +`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the new root adapter with the canonical native opener for regular-file success, pre-open traversal rejection, and Unix final-component symlink refusal. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. ### Realistic threats -Relevant threats are local project corruption after a reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, and attempts to smuggle traversal-like artifact names through typed but untrusted durable data. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. +Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution of a different project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. ### Remaining risk -The production Tauri `load_project` adapter is not yet wired to this reverse ACL. Before v3 source persistence is release-ready it must derive only the app-owned artifact, acquire a regular no-link/reparse descriptor with appropriate containment/identity checks, call the reverse ACL, run applicable decode/admission, restore fresh bootstrap/native identity state, and only then allow Active Player to resolve persisted source intent against current availability. Descriptor-bound parent authority, restart fault injection, and rights-cleared Windows/macOS real-audio acceptance remain separate required evidence. +The reusable native boundary now exists, but production Tauri `load_project` is not yet wired to it. Before v3 source persistence is release-ready, `load_project` must derive the app-local project root without creating or following a replacement project directory, call `re_admit_local_audio_publication_from_project_root` with the canonical no-follow opener, restore `LocalAudioPublicationIdentityState` and fresh `ProjectBootstrapSummaryPayload`, and then let applicable decode/admission establish runtime analysis/playback authority. Descriptor-bound parent-directory authority remains a known gap: O_NOFOLLOW protects the final source component, but a raced parent replacement needs a directory-handle-relative design or equivalent platform primitive. Restart fault injection, Active Player source reconciliation, and rights-cleared Windows/macOS real-audio acceptance also remain required evidence. ## Standards traceability -NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise FIPS 180-4, including removing SHA-1 and updating guidance, but the replacement standard has not superseded FIPS 180-4 as of this decision record. This use of SHA-256 is an integrity equality check and does not claim FIPS 140 module validation or CAVP validation. +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise FIPS 180-4, including removal of SHA-1 and updated guidance, but the replacement has not superseded FIPS 180-4 as of this record. -The implementation also follows the released NIST SSDF 1.1 principle of addressing root causes through explicit development and verification controls. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft rather than the released normative baseline used here. +The implementation follows the released NIST SSDF 1.1 principle of addressing root causes through explicit development and verification controls. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft; its public-comment period closed on January 30, 2026, so it is tracked as a draft rather than substituted for the released 1.1 baseline. ## References @@ -101,4 +98,4 @@ National Institute of Standards and Technology. (2023, March 7). *Decision to re Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 -Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd From ece508c6bddd42da06ba0a0278c1baf9d1fd2949 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:06:00 +0900 Subject: [PATCH 364/448] test(project): require non-provisioning restart root lookup --- .../project_persistence_open_authority.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index 81a458a6b..c99f0c2cf 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -158,3 +158,90 @@ fn restart_adapter_refuses_a_symlink_source_artifact() { assert_eq!(error, "Could not prepare the local project workspace."); fs::remove_dir_all(root).expect("test directory should be removable"); } + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[test] +fn restart_lookup_requires_an_existing_regular_project_directory() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-existing-project-root-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&base_root).expect("base root should be created"); + let project_id = "project-1-1"; + let project_root = base_root.join(project_id); + fs::create_dir(&project_root).expect("project root should be created"); + + let resolved = project_persistence::resolve_existing_project_root(&base_root, project_id) + .expect("an existing regular project directory should resolve"); + + assert_eq!(resolved, project_root); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} + +#[test] +fn restart_lookup_does_not_create_a_missing_project_directory() { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-missing-project-root-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&base_root).expect("base root should be created"); + let project_id = "project-1-1"; + let project_root = base_root.join(project_id); + + let error = project_persistence::resolve_existing_project_root(&base_root, project_id) + .expect_err("restart must not provision a missing project directory"); + + assert_eq!(error, "Could not prepare the local project workspace."); + assert!( + !project_root.exists(), + "read-side restart lookup must remain non-provisioning" + ); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} + +#[cfg(unix)] +#[test] +fn restart_lookup_refuses_a_symlink_project_directory() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let base_root = std::env::temp_dir().join(format!( + "bandscope-linked-project-root-{}-{nonce}", + std::process::id() + )); + let external_root = base_root.join("external"); + fs::create_dir_all(&external_root).expect("external root should be created"); + let project_id = "project-1-1"; + symlink(&external_root, base_root.join(project_id)).expect("fixture symlink should be created"); + + let error = project_persistence::resolve_existing_project_root(&base_root, project_id) + .expect_err("restart must not follow a project-directory symlink"); + + assert_eq!(error, "Could not prepare the local project workspace."); + fs::remove_dir_all(base_root).expect("test directory should be removable"); +} From f7e868564ac4fb88953b660709b96dee40b604e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:07:27 +0900 Subject: [PATCH 365/448] fix(project): add non-provisioning restart root resolver --- apps/desktop/src-tauri/src/project_root.rs | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/desktop/src-tauri/src/project_root.rs diff --git a/apps/desktop/src-tauri/src/project_root.rs b/apps/desktop/src-tauri/src/project_root.rs new file mode 100644 index 000000000..3ed73201d --- /dev/null +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -0,0 +1,45 @@ +use bandscope_desktop_core::is_valid_project_id; +use std::{fs, path::{Path, PathBuf}}; + +const PROJECT_ROOT_ERROR: &str = "Could not prepare the local project workspace."; + +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + +#[cfg(windows)] +fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + +/// Resolve one already-provisioned app-local project directory without creating it. +/// +/// Security Notes: `project_id` is validated before joining. The final project +/// directory must already exist as a real directory rather than a symlink or +/// Windows reparse point. This read-side resolver never calls `create_dir_all`, +/// so a missing or replaced project root cannot be silently provisioned during +/// reopen. Descriptor-bound parent-directory authority remains a separate +/// platform-hardening requirement. +pub(crate) fn resolve_existing_project_root( + base_root: &Path, + project_id: &str, +) -> Result { + if !is_valid_project_id(project_id) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + let project_root = base_root.join(project_id); + let metadata = fs::symlink_metadata(&project_root) + .map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !metadata_is_safe_existing_project_directory(&metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + + Ok(project_root) +} From b2fb79833ca917084777c206808d0a67a7be1bdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:08:38 +0900 Subject: [PATCH 366/448] test(project): bind restart lookup to read-side resolver --- .../src-tauri/tests/project_persistence_open_authority.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs index c99f0c2cf..96961e962 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_open_authority.rs @@ -1,5 +1,7 @@ #[path = "../src/project_persistence.rs"] mod project_persistence; +#[path = "../src/project_root.rs"] +mod project_root; use bandscope_desktop_core::{ re_admit_local_audio_publication_from_project_root, sha256_hex_reader, @@ -180,7 +182,7 @@ fn restart_lookup_requires_an_existing_regular_project_directory() { let project_root = base_root.join(project_id); fs::create_dir(&project_root).expect("project root should be created"); - let resolved = project_persistence::resolve_existing_project_root(&base_root, project_id) + let resolved = project_root::resolve_existing_project_root(&base_root, project_id) .expect("an existing regular project directory should resolve"); assert_eq!(resolved, project_root); @@ -206,7 +208,7 @@ fn restart_lookup_does_not_create_a_missing_project_directory() { let project_id = "project-1-1"; let project_root = base_root.join(project_id); - let error = project_persistence::resolve_existing_project_root(&base_root, project_id) + let error = project_root::resolve_existing_project_root(&base_root, project_id) .expect_err("restart must not provision a missing project directory"); assert_eq!(error, "Could not prepare the local project workspace."); @@ -239,7 +241,7 @@ fn restart_lookup_refuses_a_symlink_project_directory() { let project_id = "project-1-1"; symlink(&external_root, base_root.join(project_id)).expect("fixture symlink should be created"); - let error = project_persistence::resolve_existing_project_root(&base_root, project_id) + let error = project_root::resolve_existing_project_root(&base_root, project_id) .expect_err("restart must not follow a project-directory symlink"); assert_eq!(error, "Could not prepare the local project workspace."); From 9cd4681ccc8fb1f1ed9e5cacc9f6da5e12086f06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:09:24 +0900 Subject: [PATCH 367/448] test(project): require production restart source re-admission --- .../tests/local_audio_publication_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index de4b17ec9..ad3c6f02b 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -109,3 +109,41 @@ fn project_save_binds_only_explicit_project_id_to_retained_native_source_identit "multiple project aggregates forbid a global last-selected shortcut" ); } + +#[test] +fn project_load_re_admits_persisted_source_before_returning_document() { + let source = include_str!("../src/main.rs"); + let load_start = source + .find("fn load_project(") + .expect("native project load command must remain present"); + let load_tail = &source[load_start..]; + let load_end = load_tail + .find("\n}\n\nfn scores_root_for_project") + .expect("load command boundary must remain inspectable"); + let load_command = &load_tail[..load_end]; + + assert!( + source.contains("fn restore_project_source_after_restart("), + "restart needs one native adapter that restores source authority from persisted evidence" + ); + assert!( + load_command.contains("app: tauri::AppHandle"), + "load must resolve the app-local project root inside the native boundary" + ); + assert!( + load_command.contains("state: tauri::State<'_, AppState>"), + "load must restore fresh native bootstrap state for the exact project aggregate" + ); + assert!( + load_command.contains("publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>"), + "load must restore path-free publication identity only after re-admission" + ); + assert!( + load_command.contains("restore_project_source_after_restart("), + "a v3 source reference must be re-admitted before the loaded document is returned" + ); + assert!( + !load_command.contains("app_owned_root(&app, \"projects\""), + "restart must not provision a missing project directory while reading" + ); +} From 0f20b072a245feca59c72ac29b21968b41982f46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:14:23 +0900 Subject: [PATCH 368/448] fix(project): restore verified source authority on reopen --- apps/desktop/src-tauri/src/main.rs | 72 +++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 014e12a2d..998cd08f8 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod project_persistence; +mod project_root; use bandscope_desktop_core::*; use rfd::FileDialog; @@ -473,6 +474,67 @@ fn project_document_with_retained_source_reference( Ok(document) } +/// Rebuild native full-mix authority for one persisted v3 project before returning it. +/// +/// Security Notes: the persisted `sourceReference` is evidence only. The project +/// root is resolved from the Tauri app-local base without provisioning a missing +/// directory; the source is then opened through the canonical no-follow/reparse +/// Project Persistence opener and must reproduce the persisted bounded byte count +/// and SHA-256. Only after that verification do native publication and bootstrap +/// maps regain authority. Cache/temp workspaces are provisioned after source +/// re-admission, so a forged or missing project source cannot cause read-side +/// project-directory creation. +fn restore_project_source_after_restart( + app: &tauri::AppHandle, + state: &AppState, + publication_state: &LocalAudioPublicationIdentityState, + document: &ProjectDocumentPayload, +) -> Result<(), String> { + let Some(reference) = document.source_reference.as_ref() else { + return Ok(()); + }; + + let base_root = app + .path() + .app_local_data_dir() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + let project_root = project_root::resolve_existing_project_root(&base_root, &reference.project_id)?; + let reopened = re_admit_local_audio_publication_from_project_root( + &project_root, + reference, + project_persistence::open_project_file, + )?; + + let cache_root = app_owned_root(app, "cache", &reference.project_id)?; + let temp_root = app_owned_root(app, "temp", &reference.project_id)?; + let summary = ProjectBootstrapSummaryPayload { + project_id: reference.project_id.clone(), + source_mode: "reference".into(), + project_root: project_root.to_string_lossy().into_owned(), + cache_root: cache_root.to_string_lossy().into_owned(), + temp_root: temp_root.to_string_lossy().into_owned(), + source: LocalAudioSourcePayload { + source_path: reopened.source_path.to_string_lossy().into_owned(), + file_name: reference.artifact_name.clone(), + extension: reference.extension.clone(), + file_size_bytes: reference.file_size_bytes, + }, + }; + + let mut identities = publication_state + .0 + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + let mut sources = state + .0 + .bootstrap_sources + .lock() + .map_err(|_| "Could not prepare the local project workspace.".to_string())?; + identities.insert(reference.project_id.clone(), reopened.identity); + sources.insert(reference.project_id.clone(), summary); + Ok(()) +} + fn lookup_bootstrap_source( state: &AppState, project_id: &str, @@ -938,7 +1000,11 @@ fn save_project( } #[tauri::command] -fn load_project() -> Result { +fn load_project( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, +) -> Result { let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) .pick_file() @@ -946,7 +1012,9 @@ fn load_project() -> Result { project_persistence::recover_project_publication(&path)?; let content = project_persistence::read_project_file(&path)?; - project_document_from_content(&content) + let document = project_document_from_content(&content)?; + restore_project_source_after_restart(&app, &state, &publication_state, &document)?; + Ok(document) } fn scores_root_for_project( From ddeff8b48b59ef9e43804d9cd6a569ee2c4aefbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:15:47 +0900 Subject: [PATCH 369/448] style(project): rustfmt restart root resolver --- apps/desktop/src-tauri/src/project_root.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/project_root.rs b/apps/desktop/src-tauri/src/project_root.rs index 3ed73201d..1106bf345 100644 --- a/apps/desktop/src-tauri/src/project_root.rs +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -1,5 +1,8 @@ use bandscope_desktop_core::is_valid_project_id; -use std::{fs, path::{Path, PathBuf}}; +use std::{ + fs, + path::{Path, PathBuf}, +}; const PROJECT_ROOT_ERROR: &str = "Could not prepare the local project workspace."; From 3e219731283fe58af70027e505a72abcea1efbaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:16:54 +0900 Subject: [PATCH 370/448] docs(project): trace production restart re-admission --- .../project-v3-source-restart-readmission.md | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md index 417bdeddb..4190f2081 100644 --- a/docs/traceability/project-v3-source-restart-readmission.md +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -2,21 +2,21 @@ ## Problem -Project format v3 can persist a path-free `sourceReference` after Resource Admission has materialized and verified the app-owned full-mix artifact. On restart, however, persisted evidence must not become filesystem or playback authority merely because its JSON shape is valid. A replaced, truncated, extended, or same-size-mutated `source.` must not silently regain rehearsal authority. +Project format v3 can persist a path-free `sourceReference` after Resource Admission has materialized and verified the app-owned full-mix artifact. On restart, persisted evidence must not become filesystem or playback authority merely because its JSON shape is valid. A replaced, truncated, extended, or same-size-mutated `source.` must not silently regain rehearsal authority. -The Save path keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. Restart therefore needs two distinct steps: validate that durable evidence before any filesystem lookup is attempted, then re-establish native content identity only from an app-owned descriptor whose bytes reproduce the exact persisted receipt. +The Save path keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. Restart therefore needs three distinct steps: validate durable evidence before any filesystem lookup, resolve only an already-existing app-local project aggregate without provisioning a replacement directory, then re-establish native content identity only from an app-owned descriptor whose bytes reproduce the exact persisted receipt. ## Constraints - Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. - Persisted JSON is evidence, not permission to open a path. - Durable fields must be validated before an opener receives any derived artifact path. -- The project root must remain bound to the same BandScope project aggregate; cross-project root substitution fails closed. -- The final artifact descriptor must come from the native platform opener so O_NOFOLLOW/reparse-point and file-identity primitives are not copied into the Project Persistence ACL. +- The app-local project root must already exist, remain a real directory rather than a symlink/reparse point, and remain bound to the same BandScope project aggregate. Reopen must never call the provisioning path for that root. +- The final artifact descriptor must come from the native platform opener so O_NOFOLLOW/reparse-point and file-identity primitives are not copied into the core reverse ACL. - Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. - The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. -- Historical projects without `sourceReference` remain without source authority; migration does not invent evidence. -- A content-identity match alone does not prove descriptor-bound parent-directory containment or audio decodability. Those remain explicit native reopen responsibilities before fresh playback authority is issued. +- Historical projects without `sourceReference` remain without source authority; migration does not invent evidence or provision a project root. +- A content-identity match alone does not prove descriptor-bound parent-directory containment, future path stability, audio decodability, or current playable-stem availability. Those remain explicit runtime responsibilities before playback authority is issued. ## RED evidence @@ -24,65 +24,77 @@ The Save path keeps the original user path out of durable project truth and stor The later native-opener RED `66ed5ec328d498bae59af2814b20a16884f30bae` required restart code to stop at the canonical no-follow opener boundary rather than reconstructing an ambient pathname. That first contract deliberately could not compile on its predecessor because no project-root re-admission adapter existed. During the fix the responsibility was placed in the GUI-independent Project Persistence/Resource Admission ACL rather than duplicating platform open primitives in Tauri. -The deterministic PCM WAV bytes used by the core contract are unit fixtures only. The opener integration fixture validates filesystem authority composition, not MIR or decoder quality. Neither is production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. +`ece508c6bddd42da06ba0a0278c1baf9d1fd2949` added the next realistic filesystem RED: reopen must resolve an already-existing regular project directory, reject a missing project root without creating it, and refuse a linked project directory. The first RED intentionally referenced a not-yet-existing read-side resolver; `f7e868564ac4fb88953b660709b96dee40b604e9` introduced that resolver and `b2fb79833ca917084777c206808d0a67a7be1bdc` bound the executable tests to its final Tauri adapter module. + +`9cd4681ccc8fb1f1ed9e5cacc9f6da5e12086f06` then required the production `load_project` command itself to receive native app/state authority and invoke one restart adapter before returning a persisted v3 document. That predecessor had the reusable core ACL but no production call site, so the contract failed by construction until the following production fix. + +The deterministic PCM/WAV-like bytes used by the core and native filesystem contracts are unit fixtures only. They validate bounded content identity and filesystem authority composition, not MIR or decoder quality. They are not production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. ## Selected design -`823cd4aea009a3d0904cc9710971c70389dd6ad4` added `re_admit_local_audio_publication(reference, reader)`, with `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exporting the reverse content ACL. Current predecessor hardening through `e1158119a73a357956d042bb3d0bd977ababef8d` proves malformed evidence is rejected before reading and that native read failures collapse to the bounded workspace diagnosis. +`823cd4aea009a3d0904cc9710971c70389dd6ad4` added `re_admit_local_audio_publication(reference, reader)`, with `54390ce88fa6f082171682dc6d32ac5aa4a8cfe3` exporting the reverse content ACL. Hardening through `e1158119a73a357956d042bb3d0bd977ababef8d` proves malformed evidence is rejected before reading and native read failures collapse to the bounded workspace diagnosis. `f36996f251e0fdbe300df6f525d2b64fff785f3a` adds `re_admit_local_audio_publication_from_project_root(project_root, reference, open_file)` and the transient `ReAdmittedLocalAudioPublication` value object. `c7e112fa4da9f28ad886cdd21afa83ac6a7a3846` exports that ACL from the canonical desktop-core root. The adapter validates the durable reference first, requires the supplied project root basename to match the same `projectId`, derives the lookup only from the validated fixed `source.` identity, and then asks the injected native opener for a descriptor. Only that opened stream is hashed and compared with the persisted bounded receipt. -The native integration coverage finalized through `909d54f64889b977dc1b7e7eba10999f503005a9` composes this ACL with the existing `project_persistence::open_project_file` authority. It verifies an exact regular app-owned source can be re-admitted, traversal-like durable artifact evidence is rejected before an opener is invoked, and a Unix symlink at the final `source.wav` component is refused by no-follow handle acquisition. Core coverage in `b975843d57a6642fe54c36e242693473f8d25852` also proves a project-root mismatch fails before filesystem authority is requested. +Native opener coverage through `909d54f64889b977dc1b7e7eba10999f503005a9` verifies an exact regular app-owned source can be re-admitted, traversal-like durable artifact evidence is rejected before an opener is invoked, and a Unix symlink at the final `source.wav` component is refused by no-follow handle acquisition. Core coverage in `b975843d57a6642fe54c36e242693473f8d25852` also proves a project-root mismatch fails before filesystem authority is requested. + +The read-side resolver introduced at `f7e868564ac4fb88953b660709b96dee40b604e9` is deliberately distinct from `app_owned_root`. It validates the BandScope project id, derives the app-local child, requires that child to already exist as a real directory rather than a symlink or Windows reparse point, and never invokes `create_dir_all`. Cache and temp workspaces remain provisionable runtime resources, but production reopen creates them only after the persisted source has passed project-root and exact-byte re-admission. -The resulting runtime value contains a transient app-owned `source_path` plus the path-free `LocalAudioPublicationIdentity`. The path exists only to let native runtime code rebuild bootstrap/decoder authority; it is not serializable project truth and must never be copied back into `sourceReference`. +Production integration `0f20b072a245feca59c72ac29b21968b41982f46` wires this sequence into `load_project`. After recovery and bounded project parsing, a v3 document with `sourceReference` resolves the existing app-local project root, reopens the fixed source through `project_persistence::open_project_file`, verifies exact byte length and SHA-256, provisions cache/temp runtime roots, and atomically acquires both native state locks before restoring `LocalAudioPublicationIdentityState` and the matching `ProjectBootstrapSummaryPayload`. A legacy document without `sourceReference` returns without inventing source authority. `ddeff8b48b59ef9e43804d9cd6a569ee2c4aefbb` is formatting-only follow-up for the new resolver. + +The restored bootstrap keeps `source_path` transient in native memory. The durable document still contains no filesystem path, and renderer save IPC remains unable to author a digest, artifact name, byte count, or `sourceReference`. ## Rejected alternatives **Trust the persisted digest after schema validation.** Rejected because a syntactically valid digest only states what bytes are expected; it does not prove the current app-owned artifact still contains those bytes. +**Reuse `app_owned_root` during load.** Rejected because that function calls `create_dir_all`. A missing or replaced project aggregate must make reopen fail, not cause the read path to manufacture a directory that did not back the persisted evidence. + **Accept `artifactName` as a pathname.** Rejected because typed durable data is still untrusted. The adapter first reconstructs the canonical Resource Admission identity and derives the fixed artifact name from the admitted extension; forged path-like text fails before the opener is invoked. -**Copy O_NOFOLLOW/reparse-point logic into the reverse ACL.** Rejected because `project_persistence::open_project_file` already owns the supported-platform final-component handle primitive and native file-identity checks. The reverse ACL injects that authority instead of creating a second security implementation. +**Copy O_NOFOLLOW/reparse-point logic into the core reverse ACL.** Rejected because `project_persistence::open_project_file` already owns the supported-platform final-component handle primitive and native file-identity checks. The reverse ACL injects that authority instead of creating a second core security implementation. **Compare only file size.** Rejected because same-size replacement is a realistic integrity failure and is explicitly covered by the executable contract. **Hash until EOF without the persisted bound.** Rejected because a corrupted or replaced object could force unnecessary I/O before mismatch is known. The existing verifier reads the expected bytes and one growth probe. -**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish descriptor-bound parent location authority, decoder acceptance, or current playable-stem availability. +**Issue playback authority immediately after hash equality.** Rejected because content identity does not establish descriptor-bound parent location authority, future path stability, decoder acceptance, or current playable-stem availability. ## Security Notes ### Attack surface and trust boundary -The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri supplies the app-local project root; the ACL requires that root to remain bound to the same BandScope project id and derives only the canonical app-owned source artifact below it. +The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri derives the app-local project base from its native path API; the read-side resolver requires the exact project child to pre-exist without link/reparse indirection, and the core ACL requires that child to remain bound to the same BandScope project id. ### Allowlist and validation -The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. +The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. The Tauri read-side resolver refuses missing or linked project directories rather than provisioning them. ### Mitigations -Project Persistence supplies path-free durable evidence; the project-root ACL validates that evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. This sequencing prevents malformed durable data from reaching filesystem lookup and keeps platform security primitives single-owned. +Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. ### Safe failure -Malformed durable evidence, forged artifact names, cross-project root substitution, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission returns native source identity or playback capability. +Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or linked project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. ### Logging and privacy -The reverse ACL never receives the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure must not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. +The reverse ACL never receives the original user-selected path. SHA-256 remains purpose-bound integrity metadata. Buyer-facing failure does not expose the derived app-owned path or raw operating-system error unless a separate diagnostics contract explicitly authorizes that disclosure. ### Test points -`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the new root adapter with the canonical native opener for regular-file success, pre-open traversal rejection, and Unix final-component symlink refusal. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. +`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and now also proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. ### Realistic threats -Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution of a different project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. +Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. ### Remaining risk -The reusable native boundary now exists, but production Tauri `load_project` is not yet wired to it. Before v3 source persistence is release-ready, `load_project` must derive the app-local project root without creating or following a replacement project directory, call `re_admit_local_audio_publication_from_project_root` with the canonical no-follow opener, restore `LocalAudioPublicationIdentityState` and fresh `ProjectBootstrapSummaryPayload`, and then let applicable decode/admission establish runtime analysis/playback authority. Descriptor-bound parent-directory authority remains a known gap: O_NOFOLLOW protects the final source component, but a raced parent replacement needs a directory-handle-relative design or equivalent platform primitive. Restart fault injection, Active Player source reconciliation, and rights-cleared Windows/macOS real-audio acceptance also remain required evidence. +Production `load_project` now restores verified full-mix publication identity and native bootstrap state, but it does not yet establish release-grade end-to-end playback authority. The verified file descriptor is consumed by SHA-256 verification and a transient path is retained for the later analysis process. A local mutation or replacement after verification but before the analysis/decoder opens that path is therefore a remaining time-of-check/time-of-use gap; a descriptor/capability-bound handoff or an equivalent immutable snapshot design is required before claiming strict byte continuity into decode/playback. + +Descriptor-bound parent-directory authority also remains a known gap: final-component O_NOFOLLOW/reparse protection and a non-link project-root check do not prevent a concurrently replaced ancestor. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, actual decoder re-admission, Active Player source reconciliation, preferred-stem-to-Full-mix fallback, and rights-cleared Windows/macOS real-audio acceptance remain required evidence. ## Standards traceability From 382f029afb4a3e2d080e243215eddba02a95f0ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:18:17 +0900 Subject: [PATCH 371/448] docs(project): document restart directory guards --- apps/desktop/src-tauri/src/project_root.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src-tauri/src/project_root.rs b/apps/desktop/src-tauri/src/project_root.rs index 1106bf345..3352a1127 100644 --- a/apps/desktop/src-tauri/src/project_root.rs +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -9,6 +9,7 @@ const PROJECT_ROOT_ERROR: &str = "Could not prepare the local project workspace. #[cfg(windows)] const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +/// Return whether an existing Windows project directory is a real directory, not a reparse point. #[cfg(windows)] fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt; @@ -16,6 +17,7 @@ fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 } +/// Return whether an existing non-Windows project directory is a real directory, not a symlink. #[cfg(not(windows))] fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() From 1c8bc3d0668d505dbd94ebe23d58584270d6b09b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:33:00 +0900 Subject: [PATCH 372/448] test(project): reject linked app-local reopen base --- .../tests/project_root_existing_authority.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_root_existing_authority.rs diff --git a/apps/desktop/src-tauri/tests/project_root_existing_authority.rs b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs new file mode 100644 index 000000000..b9bbe2e92 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs @@ -0,0 +1,30 @@ +#[cfg(unix)] +#[path = "../src/project_root.rs"] +mod project_root; + +#[cfg(unix)] +#[test] +fn linked_app_local_base_cannot_authorize_a_project_root() { + use std::{fs, os::unix::fs::symlink}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_app_local_base = test_root.join("real-app-local"); + let linked_app_local_base = test_root.join("linked-app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_app_local_base.join(project_id)) + .expect("real app-local project directory should be created"); + symlink(&real_app_local_base, &linked_app_local_base) + .expect("linked app-local base should be created"); + + assert!( + project_root::resolve_existing_project_root(&linked_app_local_base, project_id).is_err(), + "a symlinked app-local base must not become native project authority" + ); + + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} From a839b5b495ccdb70ee3c37e253d58f438f6c58be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:33:17 +0900 Subject: [PATCH 373/448] fix(project): bind reopen to a real app-local base --- apps/desktop/src-tauri/src/project_root.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_root.rs b/apps/desktop/src-tauri/src/project_root.rs index 3352a1127..86c5f216e 100644 --- a/apps/desktop/src-tauri/src/project_root.rs +++ b/apps/desktop/src-tauri/src/project_root.rs @@ -25,12 +25,15 @@ fn metadata_is_safe_existing_project_directory(metadata: &fs::Metadata) -> bool /// Resolve one already-provisioned app-local project directory without creating it. /// -/// Security Notes: `project_id` is validated before joining. The final project -/// directory must already exist as a real directory rather than a symlink or -/// Windows reparse point. This read-side resolver never calls `create_dir_all`, -/// so a missing or replaced project root cannot be silently provisioned during -/// reopen. Descriptor-bound parent-directory authority remains a separate -/// platform-hardening requirement. +/// Security Notes: `project_id` is validated before joining. The app-local base +/// itself and the final project directory must already exist as real directories +/// rather than symlinks or Windows reparse points. Rejecting a linked base before +/// joining prevents a stable app-local path name from redirecting reopen into a +/// different filesystem subtree. This read-side resolver never calls +/// `create_dir_all`, so a missing or replaced project root cannot be silently +/// provisioned during reopen. Descriptor-bound authority for every ancestor and +/// concurrent parent replacement remains a separate platform-hardening +/// requirement. pub(crate) fn resolve_existing_project_root( base_root: &Path, project_id: &str, @@ -39,6 +42,12 @@ pub(crate) fn resolve_existing_project_root( return Err(PROJECT_ROOT_ERROR.to_string()); } + let base_metadata = + fs::symlink_metadata(base_root).map_err(|_| PROJECT_ROOT_ERROR.to_string())?; + if !metadata_is_safe_existing_project_directory(&base_metadata) { + return Err(PROJECT_ROOT_ERROR.to_string()); + } + let project_root = base_root.join(project_id); let metadata = fs::symlink_metadata(&project_root) .map_err(|_| PROJECT_ROOT_ERROR.to_string())?; From 9a924ba49e393984cf4f86f9524bd96f88a8e736 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:34:03 +0900 Subject: [PATCH 374/448] docs(project): trace app-local reopen authority --- .../project-v3-source-restart-readmission.md | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md index 4190f2081..0f8b589a4 100644 --- a/docs/traceability/project-v3-source-restart-readmission.md +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -11,7 +11,7 @@ The Save path keeps the original user path out of durable project truth and stor - Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. - Persisted JSON is evidence, not permission to open a path. - Durable fields must be validated before an opener receives any derived artifact path. -- The app-local project root must already exist, remain a real directory rather than a symlink/reparse point, and remain bound to the same BandScope project aggregate. Reopen must never call the provisioning path for that root. +- The Tauri-provided app-local base and the project root below it must already exist as real directories rather than symlinks/reparse points. Reopen must never call the provisioning path for that project root. - The final artifact descriptor must come from the native platform opener so O_NOFOLLOW/reparse-point and file-identity primitives are not copied into the core reverse ACL. - Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. - The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. @@ -28,6 +28,8 @@ The later native-opener RED `66ed5ec328d498bae59af2814b20a16884f30bae` required `9cd4681ccc8fb1f1ed9e5cacc9f6da5e12086f06` then required the production `load_project` command itself to receive native app/state authority and invoke one restart adapter before returning a persisted v3 document. That predecessor had the reusable core ACL but no production call site, so the contract failed by construction until the following production fix. +`1c8bc3d0668d505dbd94ebe23d58584270d6b09b` adds the app-local-base authority regression. It constructs a valid project directory below a real app-local fixture, exposes that fixture only through a symlinked base path, and requires reopen root resolution to reject the linked base instead of treating the ordinary child directory reached through it as app-owned authority. The predecessor checked only the final project child and therefore admitted that redirection. + The deterministic PCM/WAV-like bytes used by the core and native filesystem contracts are unit fixtures only. They validate bounded content identity and filesystem authority composition, not MIR or decoder quality. They are not production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. ## Selected design @@ -38,7 +40,7 @@ The deterministic PCM/WAV-like bytes used by the core and native filesystem cont Native opener coverage through `909d54f64889b977dc1b7e7eba10999f503005a9` verifies an exact regular app-owned source can be re-admitted, traversal-like durable artifact evidence is rejected before an opener is invoked, and a Unix symlink at the final `source.wav` component is refused by no-follow handle acquisition. Core coverage in `b975843d57a6642fe54c36e242693473f8d25852` also proves a project-root mismatch fails before filesystem authority is requested. -The read-side resolver introduced at `f7e868564ac4fb88953b660709b96dee40b604e9` is deliberately distinct from `app_owned_root`. It validates the BandScope project id, derives the app-local child, requires that child to already exist as a real directory rather than a symlink or Windows reparse point, and never invokes `create_dir_all`. Cache and temp workspaces remain provisionable runtime resources, but production reopen creates them only after the persisted source has passed project-root and exact-byte re-admission. +The read-side resolver introduced at `f7e868564ac4fb88953b660709b96dee40b604e9` is deliberately distinct from `app_owned_root`. It validates the BandScope project id, derives the app-local child, requires that child to already exist as a real directory rather than a symlink or Windows reparse point, and never invokes `create_dir_all`. `a839b5b495ccdb70ee3c37e253d58f438f6c58be` additionally validates the Tauri-provided app-local base itself before joining the project id, so a directly linked/reparse app-local base cannot redirect reopen into another subtree. Cache and temp workspaces remain provisionable runtime resources, but production reopen creates them only after the persisted source has passed project-root and exact-byte re-admission. Production integration `0f20b072a245feca59c72ac29b21968b41982f46` wires this sequence into `load_project`. After recovery and bounded project parsing, a v3 document with `sourceReference` resolves the existing app-local project root, reopens the fixed source through `project_persistence::open_project_file`, verifies exact byte length and SHA-256, provisions cache/temp runtime roots, and atomically acquires both native state locks before restoring `LocalAudioPublicationIdentityState` and the matching `ProjectBootstrapSummaryPayload`. A legacy document without `sourceReference` returns without inventing source authority. `ddeff8b48b59ef9e43804d9cd6a569ee2c4aefbb` is formatting-only follow-up for the new resolver. @@ -50,6 +52,8 @@ The restored bootstrap keeps `source_path` transient in native memory. The durab **Reuse `app_owned_root` during load.** Rejected because that function calls `create_dir_all`. A missing or replaced project aggregate must make reopen fail, not cause the read path to manufacture a directory that did not back the persisted evidence. +**Trust a linked app-local base because its project child is a regular directory.** Rejected because the child check occurs after ancestor traversal. A stable-looking app-local path can otherwise redirect native reopen into a different subtree before the child metadata is inspected. + **Accept `artifactName` as a pathname.** Rejected because typed durable data is still untrusted. The adapter first reconstructs the canonical Resource Admission identity and derives the fixed artifact name from the admitted extension; forged path-like text fails before the opener is invoked. **Copy O_NOFOLLOW/reparse-point logic into the core reverse ACL.** Rejected because `project_persistence::open_project_file` already owns the supported-platform final-component handle primitive and native file-identity checks. The reverse ACL injects that authority instead of creating a second core security implementation. @@ -64,19 +68,19 @@ The restored bootstrap keeps `source_path` transient in native memory. The durab ### Attack surface and trust boundary -The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri derives the app-local project base from its native path API; the read-side resolver requires the exact project child to pre-exist without link/reparse indirection, and the core ACL requires that child to remain bound to the same BandScope project id. +The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri derives the app-local project base from its native path API; the read-side resolver requires that base and the exact project child to pre-exist without direct link/reparse indirection, and the core ACL requires that child to remain bound to the same BandScope project id. ### Allowlist and validation -The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. The Tauri read-side resolver refuses missing or linked project directories rather than provisioning them. +The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. The Tauri read-side resolver refuses a missing, linked, or reparse app-local base/project directory rather than provisioning it. ### Mitigations -Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. +Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate below a directly non-linked app-local base; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. ### Safe failure -Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or linked project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. +Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or directly linked/reparse app-local base or project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. ### Logging and privacy @@ -84,17 +88,17 @@ The reverse ACL never receives the original user-selected path. SHA-256 remains ### Test points -`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and now also proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. +`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/project_root_existing_authority.rs` adds the direct app-local-base redirection regression. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. ### Realistic threats -Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. +Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, direct link/reparse redirection of the app-local base or project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. ### Remaining risk Production `load_project` now restores verified full-mix publication identity and native bootstrap state, but it does not yet establish release-grade end-to-end playback authority. The verified file descriptor is consumed by SHA-256 verification and a transient path is retained for the later analysis process. A local mutation or replacement after verification but before the analysis/decoder opens that path is therefore a remaining time-of-check/time-of-use gap; a descriptor/capability-bound handoff or an equivalent immutable snapshot design is required before claiming strict byte continuity into decode/playback. -Descriptor-bound parent-directory authority also remains a known gap: final-component O_NOFOLLOW/reparse protection and a non-link project-root check do not prevent a concurrently replaced ancestor. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, actual decoder re-admission, Active Player source reconciliation, preferred-stem-to-Full-mix fallback, and rights-cleared Windows/macOS real-audio acceptance remain required evidence. +Descriptor-bound parent-directory authority also remains a known gap: direct app-local-base/project-root checks and final-component O_NOFOLLOW/reparse protection do not prevent concurrent replacement of those directories or redirection through an ancestor above the checked base. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, actual decoder re-admission, Active Player source reconciliation, preferred-stem-to-Full-mix fallback, and rights-cleared Windows/macOS real-audio acceptance remain required evidence. ## Standards traceability From 232db736fe6a2dc4ad24ae60dd08a06df739ab84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:34:42 +0900 Subject: [PATCH 375/448] test(project): cover Windows app-local reparse base --- .../tests/project_root_existing_authority.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/project_root_existing_authority.rs b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs index b9bbe2e92..ad7d7b725 100644 --- a/apps/desktop/src-tauri/tests/project_root_existing_authority.rs +++ b/apps/desktop/src-tauri/tests/project_root_existing_authority.rs @@ -1,4 +1,3 @@ -#[cfg(unix)] #[path = "../src/project_root.rs"] mod project_root; @@ -28,3 +27,36 @@ fn linked_app_local_base_cannot_authorize_a_project_root() { fs::remove_dir_all(&test_root).expect("test directory should be removable"); } + +#[cfg(windows)] +#[test] +fn reparse_app_local_base_cannot_authorize_a_project_root() { + use std::{fs, process::Command}; + + let test_root = std::env::temp_dir().join(format!( + "bandscope-project-root-authority-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let real_app_local_base = test_root.join("real-app-local"); + let linked_app_local_base = test_root.join("linked-app-local"); + let project_id = "project-1-1"; + + fs::create_dir_all(real_app_local_base.join(project_id)) + .expect("real app-local project directory should be created"); + let junction = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked_app_local_base) + .arg(&real_app_local_base) + .status() + .expect("junction command should start"); + assert!(junction.success(), "junction fixture should be created"); + + assert!( + project_root::resolve_existing_project_root(&linked_app_local_base, project_id).is_err(), + "a reparse app-local base must not become native project authority" + ); + + fs::remove_dir(&linked_app_local_base).expect("junction should be removable"); + fs::remove_dir_all(&test_root).expect("test directory should be removable"); +} From 90f60a744f5dec46f364ae8d3c5e401af68983b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:07:11 +0900 Subject: [PATCH 376/448] test(project): require fresh source bytes at analysis dispatch --- .../tests/analysis_dispatch_revalidation.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs new file mode 100644 index 000000000..092a1bb58 --- /dev/null +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -0,0 +1,80 @@ +#[path = "../src/analysis_source.rs"] +mod analysis_source; + +use analysis_source::revalidate_local_audio_bootstrap_for_analysis; +use bandscope_desktop_core::{ + build_local_audio_publication_identity, LocalAudioCopyReceipt, LocalAudioSourcePayload, + ProjectBootstrapSummaryPayload, +}; +use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + +const WAV_BYTES: &[u8] = b"RIFF\x04\x00\x00\x00WAVE"; +const WAV_SHA256: &str = "1fe5a351bf0314c8a1840b023fd1e4cab3f0f123468940c241bd7bf20e989ab8"; + +fn unique_project_root() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-analysis-dispatch-{suffix}")).join("project-1-1") +} + +fn bootstrap(project_root: &std::path::Path) -> ProjectBootstrapSummaryPayload { + ProjectBootstrapSummaryPayload { + project_id: "project-1-1".to_string(), + source_mode: "reference".to_string(), + project_root: project_root.to_string_lossy().into_owned(), + cache_root: project_root.join("cache").to_string_lossy().into_owned(), + temp_root: project_root.join("temp").to_string_lossy().into_owned(), + source: LocalAudioSourcePayload { + source_path: project_root.join("source.wav").to_string_lossy().into_owned(), + file_name: "rehearsal.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: WAV_BYTES.len() as u64, + }, + } +} + +fn retained_identity() -> bandscope_desktop_core::LocalAudioPublicationIdentity { + build_local_audio_publication_identity( + "project-1-1", + "wav", + &LocalAudioCopyReceipt { + file_size_bytes: WAV_BYTES.len() as u64, + content_sha256: WAV_SHA256.to_string(), + }, + ) + .expect("fixture identity should be valid") +} + +#[test] +fn analysis_dispatch_revalidates_current_app_owned_bytes() { + let project_root = unique_project_root(); + fs::create_dir_all(&project_root).expect("project root should be created"); + let source_path = project_root.join("source.wav"); + fs::write(&source_path, WAV_BYTES).expect("source fixture should be written"); + + let refreshed = revalidate_local_audio_bootstrap_for_analysis( + &bootstrap(&project_root), + &retained_identity(), + fs::File::open, + ) + .expect("unchanged app-owned bytes should regain dispatch authority"); + assert_eq!(refreshed.source.source_path, source_path.to_string_lossy()); + assert_eq!(refreshed.source.file_size_bytes, WAV_BYTES.len() as u64); + + let mut changed = WAV_BYTES.to_vec(); + changed[changed.len() - 1] = b'A'; + fs::write(&source_path, changed).expect("same-size mutation should be written"); + + let error = revalidate_local_audio_bootstrap_for_analysis( + &bootstrap(&project_root), + &retained_identity(), + fs::File::open, + ) + .expect_err("same-size mutation must fail before analysis dispatch"); + assert_eq!(error, "Analysis job source was not found. Choose local audio again."); + + fs::remove_dir_all(project_root.parent().expect("project root should have parent")) + .expect("fixture should be removed"); +} From ae1f568591c9b9901ef2331f91068a6e1f91d561 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:07:27 +0900 Subject: [PATCH 377/448] fix(project): revalidate app-owned audio before dispatch --- apps/desktop/src-tauri/src/analysis_source.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 apps/desktop/src-tauri/src/analysis_source.rs diff --git a/apps/desktop/src-tauri/src/analysis_source.rs b/apps/desktop/src-tauri/src/analysis_source.rs new file mode 100644 index 000000000..fe16b8430 --- /dev/null +++ b/apps/desktop/src-tauri/src/analysis_source.rs @@ -0,0 +1,51 @@ +use bandscope_desktop_core::{ + project_source_reference_from_publication_identity, + re_admit_local_audio_publication_from_project_root, + LocalAudioPublicationIdentity, ProjectBootstrapSummaryPayload, +}; +use std::{io::Read, path::Path}; + +const ANALYSIS_SOURCE_NOT_FOUND: &str = + "Analysis job source was not found. Choose local audio again."; + +/// Re-establish current app-owned source bytes immediately before analysis dispatch. +/// +/// Security Notes: the bootstrap path is transient native state, not durable +/// evidence. The retained path-free Resource Admission identity is projected +/// through the Project Persistence ACL, the fixed `source.` artifact +/// is reopened by the supplied no-follow/reparse-aware native opener, and the +/// current bytes must reproduce the retained bounded size and SHA-256 before +/// they can be sent to the analysis process. OS/file-system details are reduced +/// to the stable buyer-facing re-selection error. +/// +/// This narrows the restart-to-dispatch mutation window but does not claim +/// descriptor-to-decoder continuity: the analysis process still opens the +/// returned transient path after this function releases the verified reader. +pub fn revalidate_local_audio_bootstrap_for_analysis( + bootstrap: &ProjectBootstrapSummaryPayload, + identity: &LocalAudioPublicationIdentity, + open_file: F, +) -> Result +where + R: Read, + F: FnOnce(&Path) -> std::io::Result, +{ + if bootstrap.project_id != identity.project_id { + return Err(ANALYSIS_SOURCE_NOT_FOUND.to_string()); + } + + let reference = project_source_reference_from_publication_identity(identity) + .map_err(|_| ANALYSIS_SOURCE_NOT_FOUND.to_string())?; + let reopened = re_admit_local_audio_publication_from_project_root( + Path::new(&bootstrap.project_root), + &reference, + open_file, + ) + .map_err(|_| ANALYSIS_SOURCE_NOT_FOUND.to_string())?; + + let mut refreshed = bootstrap.clone(); + refreshed.source.source_path = reopened.source_path.to_string_lossy().into_owned(); + refreshed.source.extension = reopened.identity.extension; + refreshed.source.file_size_bytes = reopened.identity.file_size_bytes; + Ok(refreshed) +} From b84ed0e39d533ef5524d25c7d86bc0fcf0197d16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:12:52 +0900 Subject: [PATCH 378/448] fix(project): revalidate source before analysis dispatch --- apps/desktop/src-tauri/src/main.rs | 36 +++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 998cd08f8..b7bf34934 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,8 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod analysis_source; mod project_persistence; mod project_root; +use analysis_source::revalidate_local_audio_bootstrap_for_analysis; use bandscope_desktop_core::*; use rfd::FileDialog; use serde_json::{json, Value}; @@ -750,6 +752,7 @@ fn start_analysis_job( request: Value, app: tauri::AppHandle, state: tauri::State<'_, AppState>, + publication_state: tauri::State<'_, LocalAudioPublicationIdentityState>, ) -> AnalysisJobStatus { let requested_at = iso_timestamp_now(); let mut parsed_request = match parse_request_payload(request) { @@ -784,6 +787,37 @@ fn start_analysis_job( ) } }; + let identity = match publication_state + .0 + .lock() + .ok() + .and_then(|identities| identities.get(&project_id).cloned()) + { + Some(identity) => identity, + None => { + return failed_status( + "invalid-job".into(), + requested_at, + AnalysisJobErrorCode::NotFound, + "Analysis job source was not found. Choose local audio again.", + ) + } + }; + let bootstrap = match revalidate_local_audio_bootstrap_for_analysis( + &bootstrap, + &identity, + project_persistence::open_project_file, + ) { + Ok(bootstrap) => bootstrap, + Err(message) => { + return failed_status( + "invalid-job".into(), + requested_at, + AnalysisJobErrorCode::NotFound, + &message, + ) + } + }; parsed_request.source_label = bootstrap.source.file_name.clone(); parsed_request.cache_root = Some(bootstrap.cache_root.clone()); parsed_request.temp_root = Some(bootstrap.temp_root.clone()); @@ -1130,4 +1164,4 @@ fn main() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file From f5730fd0c237b02259cf25cf5570cbc0987a92c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:13:24 +0900 Subject: [PATCH 379/448] test(project): format dispatch revalidation contract --- .../tests/analysis_dispatch_revalidation.rs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs index 092a1bb58..c04b88018 100644 --- a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -6,7 +6,11 @@ use bandscope_desktop_core::{ build_local_audio_publication_identity, LocalAudioCopyReceipt, LocalAudioSourcePayload, ProjectBootstrapSummaryPayload, }; -use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; const WAV_BYTES: &[u8] = b"RIFF\x04\x00\x00\x00WAVE"; const WAV_SHA256: &str = "1fe5a351bf0314c8a1840b023fd1e4cab3f0f123468940c241bd7bf20e989ab8"; @@ -16,7 +20,9 @@ fn unique_project_root() -> PathBuf { .duration_since(UNIX_EPOCH) .expect("system clock should be after epoch") .as_nanos(); - std::env::temp_dir().join(format!("bandscope-analysis-dispatch-{suffix}")).join("project-1-1") + std::env::temp_dir() + .join(format!("bandscope-analysis-dispatch-{suffix}")) + .join("project-1-1") } fn bootstrap(project_root: &std::path::Path) -> ProjectBootstrapSummaryPayload { @@ -27,7 +33,10 @@ fn bootstrap(project_root: &std::path::Path) -> ProjectBootstrapSummaryPayload { cache_root: project_root.join("cache").to_string_lossy().into_owned(), temp_root: project_root.join("temp").to_string_lossy().into_owned(), source: LocalAudioSourcePayload { - source_path: project_root.join("source.wav").to_string_lossy().into_owned(), + source_path: project_root + .join("source.wav") + .to_string_lossy() + .into_owned(), file_name: "rehearsal.wav".to_string(), extension: "wav".to_string(), file_size_bytes: WAV_BYTES.len() as u64, @@ -64,7 +73,8 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { assert_eq!(refreshed.source.file_size_bytes, WAV_BYTES.len() as u64); let mut changed = WAV_BYTES.to_vec(); - changed[changed.len() - 1] = b'A'; + let last_byte = changed.len() - 1; + changed[last_byte] = b'A'; fs::write(&source_path, changed).expect("same-size mutation should be written"); let error = revalidate_local_audio_bootstrap_for_analysis( @@ -73,7 +83,10 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { fs::File::open, ) .expect_err("same-size mutation must fail before analysis dispatch"); - assert_eq!(error, "Analysis job source was not found. Choose local audio again."); + assert_eq!( + error, + "Analysis job source was not found. Choose local audio again." + ); fs::remove_dir_all(project_root.parent().expect("project root should have parent")) .expect("fixture should be removed"); From c6351fcd50f4db060bb36de7340361f42150a652 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:14:30 +0900 Subject: [PATCH 380/448] docs(traceability): record analysis dispatch revalidation --- .../analysis-dispatch-source-revalidation.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/traceability/analysis-dispatch-source-revalidation.md diff --git a/docs/traceability/analysis-dispatch-source-revalidation.md b/docs/traceability/analysis-dispatch-source-revalidation.md new file mode 100644 index 000000000..be59665ef --- /dev/null +++ b/docs/traceability/analysis-dispatch-source-revalidation.md @@ -0,0 +1,73 @@ +# Analysis-dispatch source revalidation + +## Problem + +Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. That proof can become stale while a project remains open. Before this change, `start_analysis_job` reused the previously stored transient `source_path` without rechecking the retained Resource Admission byte identity, so a same-size mutation after load could reach the decoder under stale authority. + +## Constraints + +- Resource Admission remains the owner of local-audio byte identity; Project Persistence remains the owner of durable `sourceReference`; the analysis adapter consumes both without minting a second digest contract. +- Renderer IPC supplies only the BandScope project id for local audio. It cannot submit a path, byte count, digest, artifact name, or `localSource` payload. +- The current app-owned artifact must reproduce the retained bounded size and SHA-256 through the existing no-follow/reparse-aware native opener before a job is queued. +- Operating-system path and I/O details must collapse to the stable buyer-facing re-selection message. +- This is a dispatch-time freshness check, not descriptor-to-decoder continuity. The child analysis process still opens the returned transient path after the verified native reader has been released. + +## RED evidence + +`90f60a744f5dec46f364ae8d3c5e401af68983b7` adds `analysis_dispatch_revalidation.rs`. The contract requires unchanged app-owned bytes to regain dispatch authority and a same-size byte mutation to fail before analysis dispatch. The RED references a not-yet-existing `analysis_source` adapter, so its predecessor cannot compile that contract. An immediate descendant was pushed; no hosted RED failure receipt is claimed. + +The deterministic twelve-byte RIFF/WAVE fixture tests content identity only. It is not MIR, decoder-quality, or production scientific acceptance evidence. + +## Selected design + +`ae1f568591c9b9901ef2331f91068a6e1f91d561` introduces the GUI-independent `revalidate_local_audio_bootstrap_for_analysis` adapter. It projects the retained `LocalAudioPublicationIdentity` through the existing Project Persistence source-reference ACL, reopens only the fixed app-owned artifact through the injected native opener, and reuses the existing bounded re-admission verifier. On success it refreshes only transient source path/extension/size fields; it does not create durable evidence. + +`b84ed0e39d533ef5524d25c7d86bc0fcf0197d16` wires the adapter into the production `start_analysis_job` command. The command now obtains the project-keyed native publication identity, revalidates current bytes before filling `local_source`, and fails with `NotFound` plus the existing re-selection message when the native identity or current artifact cannot be re-established. `f5730fd0c237b02259cf25cf5570cbc0987a92c3` is a formatting/borrow-check-safe cleanup of the focused regression test; it does not alter the product contract. + +## Rejected alternatives + +**Trust restart verification for the lifetime of the open project.** Rejected because native bootstrap state is cached and can outlive later file mutation. + +**Let the renderer resubmit a digest immediately before analysis.** Rejected because renderer data is not Resource Admission authority and would recreate the source-evidence forgery path already removed from v3 Save. + +**Rehash through a second analysis-specific implementation.** Rejected because the canonical bounded receipt verifier and source-reference ACL already exist. The dispatch adapter composes those contracts instead of creating another hash/file-size policy. + +**Claim strict byte continuity after the dispatch check.** Rejected because the child decoder still performs a later pathname open. The residual interval is smaller but non-zero. + +## Security Notes + +### Attack surface and trust boundary + +The renderer-visible project id is a selector only. Native `LocalAudioPublicationIdentityState` supplies the path-free expected identity, and native bootstrap state supplies the app-owned project root. The adapter requires both to name the same BandScope project and derives the fixed artifact from canonical identity fields. + +### Mitigations + +The same no-follow/reparse-aware Project Persistence opener used for restart re-admission is invoked again immediately before queue admission. Exact size and SHA-256 are rechecked with the existing expected-length-plus-one-byte-growth bound. Failure occurs before `parsed_request.local_source` receives dispatch authority. + +### Safe failure + +Missing retained identity, project mismatch, malformed identity, native open failure, growth, truncation, or same-size mutation returns `Analysis job source was not found. Choose local audio again.` and the job is not queued. Raw filesystem diagnostics do not cross into buyer-facing status. + +### Test points + +`apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers exact-byte success and same-size mutation failure at the dispatch adapter. Existing restart re-admission tests remain canonical for malformed durable evidence, root substitution, no-follow/reparse behavior, growth, truncation, and exact SHA-256 identity. + +### Remaining risk + +The verified descriptor is released before the Python analysis process opens `local_source.sourcePath`. A local replacement or mutation in that interval can therefore still create a TOCTOU gap. Release-grade byte continuity requires a descriptor/capability-bound decoder handoff or an equivalent supported-platform immutable-snapshot mechanism whose identity is retained through decode. Parent-directory descriptor binding and higher-ancestor replacement remain separate filesystem-authority work. + +## Standards traceability + +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise the standard, but its current publication page still identifies FIPS 180-4; the announced revision has not superseded it. + +NIST SP 800-218 v1.1 remains the released SSDF baseline. SP 800-218 Rev. 1 / SSDF 1.2 is still identified by NIST as an Initial Public Draft with the comment period closed on January 30, 2026. The repair follows the released SSDF principle of preventing recurrence by placing verification at the actual consuming boundary instead of relying on an earlier check. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs + +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd From 9dc5336d7bbd4673f4ba0722a1548596d3085bfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:07:46 +0900 Subject: [PATCH 381/448] test(audio): require content-bound decode snapshot --- .../tests/test_audio_admitted_snapshot.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 services/analysis-engine/tests/test_audio_admitted_snapshot.py diff --git a/services/analysis-engine/tests/test_audio_admitted_snapshot.py b/services/analysis-engine/tests/test_audio_admitted_snapshot.py new file mode 100644 index 000000000..6da57e2dd --- /dev/null +++ b/services/analysis-engine/tests/test_audio_admitted_snapshot.py @@ -0,0 +1,90 @@ +"""Regression contracts for admitted local-audio byte continuity.""" + +from __future__ import annotations + +import hashlib + +import numpy as np +import pytest + +import bandscope_analysis.separation.audio_separator as audio_separator_module +from bandscope_analysis.separation.audio_separator import AudioSeparationConfig, AudioStemSeparator + + +def _same_size_bytes(seed: bytes, marker: int) -> bytes: + """Return a byte-distinct payload with the same encoded length.""" + payload = bytearray(seed) + payload[-1] = marker + return bytes(payload) + + +def test_admitted_separator_rejects_same_size_replacement_before_decode( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reject a pathname replacement that no longer matches native content evidence.""" + original = b"RIFF-admitted-audio" + replacement = _same_size_bytes(original, ord("X")) + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(replacement) + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + + with pytest.raises(ValueError, match="source changed before decode"): + separator.separate_admitted( + audio_path, + expected_file_size_bytes=len(original), + expected_content_sha256=hashlib.sha256(original).hexdigest(), + ) + + assert decode_called is False + + +def test_admitted_separator_decodes_verified_snapshot_after_path_replacement( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Decode the verified snapshot even if the pathname changes after snapshotting.""" + original = b"RIFF-admitted-audio" + replacement = _same_size_bytes(original, ord("Y")) + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(original) + observed_decode_bytes: bytes | None = None + + def fake_decode(source, *, policy): + nonlocal observed_decode_bytes + audio_path.write_bytes(replacement) + source.seek(0) + observed_decode_bytes = source.read() + return np.ones(8, dtype=np.float32), policy.target_sample_rate + + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda _self, audio, _sample_rate: { + "vocals": np.zeros(audio.size, dtype=np.float32), + "bass": np.zeros(audio.size, dtype=np.float32), + "drums": np.zeros(audio.size, dtype=np.float32), + "other": np.zeros(audio.size, dtype=np.float32), + }, + ) + separator = AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + + separator.separate_admitted( + audio_path, + expected_file_size_bytes=len(original), + expected_content_sha256=hashlib.sha256(original).hexdigest(), + ) + + assert observed_decode_bytes == original + assert audio_path.read_bytes() == replacement From 93d2c99aef316fa42b8796b3b05bfea2cd46c7ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:08:40 +0900 Subject: [PATCH 382/448] fix(audio): bind decoder to admitted content snapshot --- .../separation/audio_separator.py | 105 +++++++++++++++++- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 2507bf6cf..6f45881d2 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -9,6 +9,10 @@ Security Notes: - Treats the selected audio file as untrusted input: the path is normalized and verified to be a file, and a maximum byte size is enforced before decode. +- Native-admitted sources can carry exact byte-count + SHA-256 evidence. Those + bytes are copied once from the opened descriptor into a private spooled file, + verified against the evidence, and decoded from that same snapshot. A later + pathname replacement therefore cannot change the bytes entering MIR/model work. - Decoded audio is revalidated against the same versioned resource policy before Demucs/model work so overlong, malformed, or non-finite decoder output fails closed instead of being silently truncated or normalized. @@ -27,12 +31,14 @@ from __future__ import annotations import contextlib +import hashlib import logging import os import sys +import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Any, cast +from typing import Any, BinaryIO, cast import numpy as np @@ -51,6 +57,9 @@ _STEM_ORDER: tuple[AudioStemName, ...] = ("vocals", "bass", "drums", "other") _EMPTY_RANGE_EPS = 1e-9 _MODEL_OUTPUT_ERROR = "Stem separation produced invalid audio." +_ADMITTED_SOURCE_CHANGED_ERROR = "Stem separation source changed before decode." +_SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 +_COPY_CHUNK_BYTES = 64 * 1024 def _contains_parent_path_segment(path: Path) -> bool: @@ -63,6 +72,16 @@ def _contains_parent_path_segment(path: Path) -> bool: return any(part == ".." for part in normalized_path_text.split("/")) +def _valid_sha256_hex(value: object) -> bool: + """Return whether value is one canonical lowercase SHA-256 hex digest.""" + return ( + isinstance(value, str) + and len(value) == 64 + and value == value.lower() + and all(character in "0123456789abcdef" for character in value) + ) + + @dataclass(frozen=True) class AudioSeparationConfig: """Resource and model settings for local stem separation.""" @@ -91,11 +110,39 @@ def __init__(self, config: AudioSeparationConfig | None = None) -> None: self._model: Any = None def separate(self, audio_path: str | Path) -> AudioSeparationResult: - """Separate local audio into vocals, bass, drums, and other stems.""" + """Separate one local path through the compatibility decode boundary.""" path = self._resolve_audio_file(audio_path) audio, sample_rate = self._load_audio(path) + return self._separate_loaded_audio(audio, sample_rate) + + def separate_admitted( + self, + audio_path: str | Path, + *, + expected_file_size_bytes: int, + expected_content_sha256: str, + ) -> AudioSeparationResult: + """Separate bytes that reproduce native Resource Admission evidence. + + The source pathname is resolved and opened once. Before any decoder or + model call, the opened bytes are copied into a private spooled snapshot + while exact encoded length and SHA-256 are checked. Decode then consumes + that snapshot rather than reopening the pathname. + """ + path = self._resolve_audio_file(audio_path) + audio, sample_rate = self._load_admitted_audio( + path, + expected_file_size_bytes=expected_file_size_bytes, + expected_content_sha256=expected_content_sha256, + ) + return self._separate_loaded_audio(audio, sample_rate) + + def _separate_loaded_audio( + self, audio: AudioStemArray, sample_rate: int + ) -> AudioSeparationResult: + """Separate one already-decoded admitted mono signal.""" if audio.size == 0: - raise ValueError(f"Stem separation decode failed for {path.name}") + raise ValueError("Stem separation decode failed for selected audio") stem_arrays = self._separate_signal(audio, sample_rate) stems: AudioStemPayload = { @@ -207,7 +254,7 @@ def _resolve_audio_file(self, audio_path: str | Path) -> Path: return path def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: - """Load bounded mono audio through the canonical decoder authority.""" + """Load bounded mono audio through the compatibility decoder authority.""" try: with path.open("rb") as fileobj: file_size = os.fstat(fileobj.fileno()).st_size @@ -227,6 +274,54 @@ def _load_audio(self, path: Path) -> tuple[AudioStemArray, int]: raise ValueError(f"Stem separation decode failed for {path.name}") return _as_float_array(y), int(sr) + def _load_admitted_audio( + self, + path: Path, + *, + expected_file_size_bytes: int, + expected_content_sha256: str, + ) -> tuple[AudioStemArray, int]: + """Snapshot and decode exactly the bytes admitted by native Resource Admission.""" + if ( + not isinstance(expected_file_size_bytes, int) + or isinstance(expected_file_size_bytes, bool) + or expected_file_size_bytes <= 0 + or not _valid_sha256_hex(expected_content_sha256) + ): + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + try: + self.resource_policy.validate_encoded_file_bytes(expected_file_size_bytes) + except ValueError as error: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) from error + + try: + with path.open("rb") as fileobj: + actual_size = os.fstat(fileobj.fileno()).st_size + if actual_size != expected_file_size_bytes: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + with tempfile.SpooledTemporaryFile(max_size=_SNAPSHOT_MEMORY_BYTES, mode="w+b") as snapshot: + digest = hashlib.sha256() + remaining = expected_file_size_bytes + while remaining: + chunk = fileobj.read(min(_COPY_CHUNK_BYTES, remaining)) + if not chunk: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + snapshot.write(chunk) + digest.update(chunk) + remaining -= len(chunk) + if fileobj.read(1) or digest.hexdigest() != expected_content_sha256: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + snapshot.seek(0) + y, sr = decode_mono_audio(snapshot, policy=self.resource_policy) + except ValueError: + raise + except Exception as error: + raise ValueError(f"Stem separation decode failed for {path.name}") from error + + if y.size == 0: + raise ValueError(f"Stem separation decode failed for {path.name}") + return _as_float_array(y), int(sr) + def _fit_length(self, audio: AudioStemArray, target_length: int) -> AudioStemArray: """Trim or pad a stem to match the source length exactly.""" fitted = np.zeros(target_length, dtype=np.float32) @@ -245,4 +340,4 @@ def _as_float_array(values: object) -> AudioStemArray: raise ValueError(_MODEL_OUTPUT_ERROR) from error if array.size == 0 or not np.isfinite(array).all(): raise ValueError(_MODEL_OUTPUT_ERROR) - return cast(AudioStemArray, array) \ No newline at end of file + return cast(AudioStemArray, array) From 65baf71db5ea47b607753a297908483900be9215 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:12:33 +0900 Subject: [PATCH 383/448] test(audio): require scoped native admission evidence --- .../tests/test_audio_admitted_snapshot.py | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_admitted_snapshot.py b/services/analysis-engine/tests/test_audio_admitted_snapshot.py index 6da57e2dd..88dfbf0cb 100644 --- a/services/analysis-engine/tests/test_audio_admitted_snapshot.py +++ b/services/analysis-engine/tests/test_audio_admitted_snapshot.py @@ -18,6 +18,13 @@ def _same_size_bytes(seed: bytes, marker: int) -> bytes: return bytes(payload) +def _separator() -> AudioStemSeparator: + """Build the bounded separator used by the byte-continuity regressions.""" + return AudioStemSeparator( + AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + ) + + def test_admitted_separator_rejects_same_size_replacement_before_decode( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -34,12 +41,9 @@ def fake_decode(*_args, **_kwargs): return np.ones(8, dtype=np.float32), 8_000 monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) - separator = AudioStemSeparator( - AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) - ) with pytest.raises(ValueError, match="source changed before decode"): - separator.separate_admitted( + _separator().separate_admitted( audio_path, expected_file_size_bytes=len(original), expected_content_sha256=hashlib.sha256(original).hexdigest(), @@ -76,11 +80,8 @@ def fake_decode(source, *, policy): "other": np.zeros(audio.size, dtype=np.float32), }, ) - separator = AudioStemSeparator( - AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) - ) - separator.separate_admitted( + _separator().separate_admitted( audio_path, expected_file_size_bytes=len(original), expected_content_sha256=hashlib.sha256(original).hexdigest(), @@ -88,3 +89,60 @@ def fake_decode(source, *, policy): assert observed_decode_bytes == original assert audio_path.read_bytes() == replacement + + +def test_plain_separator_consumes_scoped_native_evidence( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Require the production worker entrypoint to honor native admission evidence.""" + original = b"RIFF-admitted-audio" + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(_same_size_bytes(original, ord("Z"))) + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", str(len(original))) + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", hashlib.sha256(original).hexdigest()) + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + monkeypatch.setattr( + AudioStemSeparator, + "_separate_signal", + lambda _self, audio, _sample_rate: { + "vocals": np.zeros(audio.size, dtype=np.float32), + "bass": np.zeros(audio.size, dtype=np.float32), + "drums": np.zeros(audio.size, dtype=np.float32), + "other": np.zeros(audio.size, dtype=np.float32), + }, + ) + + with pytest.raises(ValueError, match="source changed before decode"): + _separator().separate(audio_path) + + assert decode_called is False + + +def test_plain_separator_rejects_partial_native_evidence( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail closed when a child process receives only half of native evidence.""" + audio_path = tmp_path / "source.wav" + audio_path.write_bytes(b"RIFF-admitted-audio") + decode_called = False + + def fake_decode(*_args, **_kwargs): + nonlocal decode_called + decode_called = True + return np.ones(8, dtype=np.float32), 8_000 + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", str(audio_path.stat().st_size)) + monkeypatch.delenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", raising=False) + monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) + + with pytest.raises(ValueError, match="source changed before decode"): + _separator().separate(audio_path) + + assert decode_called is False From e0bec865005e4e4b836fe76af66a6587d9f5743d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:13:31 +0900 Subject: [PATCH 384/448] fix(audio): consume scoped native admission evidence --- .../separation/audio_separator.py | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 6f45881d2..f9a31eaf0 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -9,10 +9,12 @@ Security Notes: - Treats the selected audio file as untrusted input: the path is normalized and verified to be a file, and a maximum byte size is enforced before decode. -- Native-admitted sources can carry exact byte-count + SHA-256 evidence. Those - bytes are copied once from the opened descriptor into a private spooled file, - verified against the evidence, and decoded from that same snapshot. A later - pathname replacement therefore cannot change the bytes entering MIR/model work. +- Native-admitted sources carry exact byte-count + SHA-256 evidence in the + per-analysis child-process environment. Partial or malformed evidence fails + closed. Those bytes are copied once from the opened descriptor into a private + spooled file, verified against the evidence, and decoded from that same + snapshot. A later pathname replacement therefore cannot change the bytes + entering MIR/model work. - Decoded audio is revalidated against the same versioned resource policy before Demucs/model work so overlong, malformed, or non-finite decoder output fails closed instead of being silently truncated or normalized. @@ -38,7 +40,7 @@ import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Any, BinaryIO, cast +from typing import Any, cast import numpy as np @@ -58,6 +60,8 @@ _EMPTY_RANGE_EPS = 1e-9 _MODEL_OUTPUT_ERROR = "Stem separation produced invalid audio." _ADMITTED_SOURCE_CHANGED_ERROR = "Stem separation source changed before decode." +_ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" +_ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" _SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 _COPY_CHUNK_BYTES = 64 * 1024 @@ -82,6 +86,29 @@ def _valid_sha256_hex(value: object) -> bool: ) +def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: + """Read the native-owned evidence scoped to one analysis process. + + The desktop process sets both variables on the child ``Command`` rather than + mutating its own environment, so concurrent analysis jobs cannot overwrite + one another's evidence. Missing evidence preserves compatibility for direct + library callers; a partial pair is treated as a broken trust handoff. + """ + raw_size = os.environ.get(_ADMITTED_AUDIO_BYTES_ENV) + digest = os.environ.get(_ADMITTED_AUDIO_SHA256_ENV) + if raw_size is None and digest is None: + return None + if raw_size is None or digest is None: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + try: + expected_size = int(raw_size, 10) + except ValueError as error: + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) from error + if str(expected_size) != raw_size or expected_size <= 0 or not _valid_sha256_hex(digest): + raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) + return expected_size, digest + + @dataclass(frozen=True) class AudioSeparationConfig: """Resource and model settings for local stem separation.""" @@ -110,9 +137,17 @@ def __init__(self, config: AudioSeparationConfig | None = None) -> None: self._model: Any = None def separate(self, audio_path: str | Path) -> AudioSeparationResult: - """Separate one local path through the compatibility decode boundary.""" + """Separate one local source under the active native-admission contract.""" + evidence = _admitted_audio_evidence_from_environment() path = self._resolve_audio_file(audio_path) - audio, sample_rate = self._load_audio(path) + if evidence is None: + audio, sample_rate = self._load_audio(path) + else: + audio, sample_rate = self._load_admitted_audio( + path, + expected_file_size_bytes=evidence[0], + expected_content_sha256=evidence[1], + ) return self._separate_loaded_audio(audio, sample_rate) def separate_admitted( From 404586a2eae752fa329dfc87768b22148ce9411a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:14:27 +0900 Subject: [PATCH 385/448] test(audio): require per-process decode evidence handoff --- .../src-tauri/tests/analysis_dispatch_revalidation.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs index c04b88018..c32f8044d 100644 --- a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -12,6 +12,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +const MAIN_SOURCE: &str = include_str!("../src/main.rs"); const WAV_BYTES: &[u8] = b"RIFF\x04\x00\x00\x00WAVE"; const WAV_SHA256: &str = "1fe5a351bf0314c8a1840b023fd1e4cab3f0f123468940c241bd7bf20e989ab8"; @@ -91,3 +92,11 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { fs::remove_dir_all(project_root.parent().expect("project root should have parent")) .expect("fixture should be removed"); } + +#[test] +fn analysis_process_receives_native_evidence_without_global_environment_mutation() { + assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_BYTES")); + assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_SHA256")); + assert!(MAIN_SOURCE.contains("command.env(")); + assert!(!MAIN_SOURCE.contains("std::env::set_var(\"BANDSCOPE_ADMITTED_AUDIO_")); +} From a0809cdee41100296e478c18653ab1e7f3305559 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:16:35 +0900 Subject: [PATCH 386/448] fix(audio): scope native evidence to analysis child --- apps/desktop/src-tauri/src/main.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b7bf34934..d58651d93 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -19,6 +19,9 @@ use std::{ use tauri::{Emitter, Manager, Runtime}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +const ADMITTED_AUDIO_BYTES_ENV: &str = "BANDSCOPE_ADMITTED_AUDIO_BYTES"; +const ADMITTED_AUDIO_SHA256_ENV: &str = "BANDSCOPE_ADMITTED_AUDIO_SHA256"; + /// Native-only cache of verified local-audio publication identities. /// /// Security Notes: entries are keyed only by BandScope-minted project ids and @@ -567,6 +570,7 @@ fn run_analysis_engine( app: tauri::AppHandle, job_id: String, request: AnalysisJobRequest, + admitted_identity: Option, requested_at: String, ) -> AnalysisJobStatus { let (working_dir, program, mut args) = analysis_command(); @@ -581,14 +585,25 @@ fn run_analysis_engine( } args.push("--progress-jsonl".into()); - let mut process = match Command::new(program) + let mut command = Command::new(program); + command .args(args) .current_dir(working_dir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() - { + .env_remove(ADMITTED_AUDIO_BYTES_ENV) + .env_remove(ADMITTED_AUDIO_SHA256_ENV); + if let Some(identity) = admitted_identity.as_ref() { + command + .env( + ADMITTED_AUDIO_BYTES_ENV, + identity.file_size_bytes.to_string(), + ) + .env(ADMITTED_AUDIO_SHA256_ENV, &identity.content_sha256); + } + + let mut process = match command.spawn() { Ok(process) => process, Err(_) => { return failed_status( @@ -766,6 +781,7 @@ fn start_analysis_job( ) } }; + let mut admitted_identity = None; if parsed_request.source_kind == "local_audio" { let Some(project_id) = parsed_request.project_id.clone() else { @@ -818,6 +834,7 @@ fn start_analysis_job( ) } }; + admitted_identity = Some(identity); parsed_request.source_label = bootstrap.source.file_name.clone(); parsed_request.cache_root = Some(bootstrap.cache_root.clone()); parsed_request.temp_root = Some(bootstrap.temp_root.clone()); @@ -871,6 +888,7 @@ fn start_analysis_job( worker_app_handle.clone(), job_id, parsed_request, + admitted_identity, requested_at, ); store_status_and_emit(&app_state, &worker_app_handle, &finished); @@ -1164,4 +1182,4 @@ fn main() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} \ No newline at end of file +} From cbaaf868f7fa6d1050b62eec109bdb54d69e07d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:16:57 +0900 Subject: [PATCH 387/448] test(audio): forbid duplicate CLI path decode --- .../test_cli_native_admission_boundary.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 services/analysis-engine/tests/test_cli_native_admission_boundary.py diff --git a/services/analysis-engine/tests/test_cli_native_admission_boundary.py b/services/analysis-engine/tests/test_cli_native_admission_boundary.py new file mode 100644 index 000000000..f93130f88 --- /dev/null +++ b/services/analysis-engine/tests/test_cli_native_admission_boundary.py @@ -0,0 +1,55 @@ +"""CLI trust-boundary regressions for native-admitted local audio.""" + +from __future__ import annotations + +import io +import json + +import pytest + +from bandscope_analysis import cli + + +def test_native_admission_skips_temporary_path_reopen(monkeypatch: pytest.MonkeyPatch) -> None: + """Do not decode a mutable pathname before the content-bound worker path.""" + payload = { + "jobId": "job-native-admitted", + "request": { + "sourceKind": "local_audio", + "projectId": "project-1-1", + "sourceLabel": "source.wav", + "roleFocus": [], + "localSource": { + "sourcePath": "/native/app-owned/project-1-1/source.wav", + "fileName": "source.wav", + "extension": "wav", + "fileSizeBytes": 12, + }, + }, + } + stdin = io.StringIO(json.dumps(payload)) + stdout = io.StringIO() + + class ForbiddenTemporalAnalyzer: + def __init__(self) -> None: + raise AssertionError("native-admitted audio must not be reopened by the CLI probe") + + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_BYTES", "12") + monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", "0" * 64) + monkeypatch.setattr(cli, "TemporalAnalyzer", ForbiddenTemporalAnalyzer) + monkeypatch.setattr( + cli, + "run_analysis_job", + lambda job_id, request, requested_at: { + "jobId": job_id, + "state": "succeeded", + "requestedAt": requested_at, + "updatedAt": requested_at, + }, + ) + monkeypatch.setattr(cli.sys, "stdin", stdin) + monkeypatch.setattr(cli.sys, "stdout", stdout) + monkeypatch.setattr(cli.sys, "argv", ["cli.py"]) + + assert cli.main() == 0 + assert json.loads(stdout.getvalue())["jobId"] == "job-native-admitted" From a1136c5270cfbd940d9e3e3cea7cc55b6ce1cdb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:17:17 +0900 Subject: [PATCH 388/448] fix(audio): skip duplicate path probe for admitted source --- .../src/bandscope_analysis/cli.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/cli.py b/services/analysis-engine/src/bandscope_analysis/cli.py index 6838ee711..1ddd9ce19 100644 --- a/services/analysis-engine/src/bandscope_analysis/cli.py +++ b/services/analysis-engine/src/bandscope_analysis/cli.py @@ -4,6 +4,7 @@ import json import logging +import os import sys from datetime import UTC, datetime @@ -12,6 +13,9 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +_ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" +_ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" + def failed_cli_response(message: str) -> dict[str, object]: """Return a typed CLI failure envelope for malformed stdin payloads.""" @@ -28,6 +32,14 @@ def failed_cli_response(message: str) -> dict[str, object]: } +def _native_admission_is_scoped() -> bool: + """Return whether this child process carries native audio identity evidence.""" + return ( + os.environ.get(_ADMITTED_AUDIO_BYTES_ENV) is not None + or os.environ.get(_ADMITTED_AUDIO_SHA256_ENV) is not None + ) + + def main() -> int: """Read a job payload from stdin and print a structured job response to stdout.""" # Read all input from stdin first @@ -75,10 +87,12 @@ def main() -> int: request = payload.get("request") - # Temporary: Inject temporal analyzer call if it's a local file, just to prove it works - # before full orchestrator integration + # Compatibility-only probe for direct/manual callers. Native desktop jobs + # carry Resource Admission evidence and must not reopen the mutable pathname + # before the content-bound separation/decode path consumes that evidence. if ( - isinstance(request, dict) + not _native_admission_is_scoped() + and isinstance(request, dict) and request.get("sourceKind") == "local_audio" and "localSource" in request ): @@ -90,7 +104,7 @@ def main() -> int: try: temporal_analyzer = TemporalAnalyzer() features = temporal_analyzer.analyze(audio_path) - logging.info(f"Extracted BPM: {features['bpm']}") + logging.info("Extracted BPM: %s", features["bpm"]) except Exception: logging.warning( "Temporal analysis failed for %s; continuing with safe fallback.", From e1b50929f7d112cf8fb417ede97c98af6a3c2b41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:17:40 +0900 Subject: [PATCH 389/448] test(audio): pin scoped child evidence contract --- .../src-tauri/tests/analysis_dispatch_revalidation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs index c32f8044d..33a4cefbe 100644 --- a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -97,6 +97,9 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { fn analysis_process_receives_native_evidence_without_global_environment_mutation() { assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_BYTES")); assert!(MAIN_SOURCE.contains("BANDSCOPE_ADMITTED_AUDIO_SHA256")); - assert!(MAIN_SOURCE.contains("command.env(")); + assert!(MAIN_SOURCE.contains(".env_remove(ADMITTED_AUDIO_BYTES_ENV)")); + assert!(MAIN_SOURCE.contains(".env_remove(ADMITTED_AUDIO_SHA256_ENV)")); + assert!(MAIN_SOURCE.contains("ADMITTED_AUDIO_BYTES_ENV,")); + assert!(MAIN_SOURCE.contains("ADMITTED_AUDIO_SHA256_ENV, &identity.content_sha256")); assert!(!MAIN_SOURCE.contains("std::env::set_var(\"BANDSCOPE_ADMITTED_AUDIO_")); } From 88beb62d7f1cad5fc141b73da0c7f75ec9d785fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:21:07 +0900 Subject: [PATCH 390/448] style(audio): wrap admitted snapshot construction --- .../src/bandscope_analysis/separation/audio_separator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index f9a31eaf0..040b7d222 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -334,7 +334,10 @@ def _load_admitted_audio( actual_size = os.fstat(fileobj.fileno()).st_size if actual_size != expected_file_size_bytes: raise ValueError(_ADMITTED_SOURCE_CHANGED_ERROR) - with tempfile.SpooledTemporaryFile(max_size=_SNAPSHOT_MEMORY_BYTES, mode="w+b") as snapshot: + with tempfile.SpooledTemporaryFile( + max_size=_SNAPSHOT_MEMORY_BYTES, + mode="w+b", + ) as snapshot: digest = hashlib.sha256() remaining = expected_file_size_bytes while remaining: From e11f73c62a9fffee7589e4a5162f91615514bfcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:22:29 +0900 Subject: [PATCH 391/448] docs(audio): trace content-bound decoder handoff --- .../analysis-dispatch-source-revalidation.md | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/docs/traceability/analysis-dispatch-source-revalidation.md b/docs/traceability/analysis-dispatch-source-revalidation.md index be59665ef..319eefd60 100644 --- a/docs/traceability/analysis-dispatch-source-revalidation.md +++ b/docs/traceability/analysis-dispatch-source-revalidation.md @@ -2,72 +2,102 @@ ## Problem -Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. That proof can become stale while a project remains open. Before this change, `start_analysis_job` reused the previously stored transient `source_path` without rechecking the retained Resource Admission byte identity, so a same-size mutation after load could reach the decoder under stale authority. +Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. The first dispatch repair repeated that proof immediately before queue admission, but then released the verified native reader. The Python analysis process subsequently reopened `local_source.sourcePath`, leaving a smaller TOCTOU window in which different bytes could reach decode after the native check. ## Constraints -- Resource Admission remains the owner of local-audio byte identity; Project Persistence remains the owner of durable `sourceReference`; the analysis adapter consumes both without minting a second digest contract. -- Renderer IPC supplies only the BandScope project id for local audio. It cannot submit a path, byte count, digest, artifact name, or `localSource` payload. -- The current app-owned artifact must reproduce the retained bounded size and SHA-256 through the existing no-follow/reparse-aware native opener before a job is queued. -- Operating-system path and I/O details must collapse to the stable buyer-facing re-selection message. -- This is a dispatch-time freshness check, not descriptor-to-decoder continuity. The child analysis process still opens the returned transient path after the verified native reader has been released. +- Resource Admission remains the owner of local-audio byte identity; Project Persistence remains the owner of durable `sourceReference`; analysis consumes the retained identity without minting a second digest contract. +- Renderer IPC supplies only the BandScope project id for local audio. It cannot submit a path, byte count, digest, artifact name, `sourceReference`, or native-admission evidence. +- Exact byte count and SHA-256 must survive the Rust-to-Python process boundary without process-global mutation because BandScope allows concurrent analysis jobs. +- The Python decoder must consume the same verified byte snapshot, not a pathname reopened after verification. +- Operating-system path and I/O failures remain bounded; raw local paths and native diagnostics do not become buyer-facing errors. +- Deterministic RIFF/WAVE byte strings in this lane are security/unit fixtures only. They are not MIR accuracy, decoder-quality, or production scientific acceptance evidence. -## RED evidence +## RED and repair evidence -`90f60a744f5dec46f364ae8d3c5e401af68983b7` adds `analysis_dispatch_revalidation.rs`. The contract requires unchanged app-owned bytes to regain dispatch authority and a same-size byte mutation to fail before analysis dispatch. The RED references a not-yet-existing `analysis_source` adapter, so its predecessor cannot compile that contract. An immediate descendant was pushed; no hosted RED failure receipt is claimed. +`90f60a744f5dec46f364ae8d3c5e401af68983b7` introduced dispatch-time native revalidation. `ae1f568591c9b9901ef2331f91068a6e1f91d561` composed the retained `LocalAudioPublicationIdentity` with the Project Persistence reverse ACL, and `b84ed0e39d533ef5524d25c7d86bc0fcf0197d16` wired it into `start_analysis_job`. That repair narrowed the stale interval but did not bind decoder bytes. -The deterministic twelve-byte RIFF/WAVE fixture tests content identity only. It is not MIR, decoder-quality, or production scientific acceptance evidence. +`9dc5336d7bbd4673f4ba0722a1548596d3085bfa` adds the decoder-bound RED contracts. They require a same-size replacement to fail before decode and require decoding to continue from already-verified bytes even when the pathname changes after snapshot creation. The predecessor had no `separate_admitted` boundary, so no hosted RED receipt is claimed. + +`93d2c99aef316fa42b8796b3b05bfea2cd46c7ed` adds the explicit admitted-source snapshot path. `65baf71db5ea47b607753a297908483900be9215` then adds a second RED requiring the production `AudioStemSeparator.separate` entrypoint to consume process-scoped native evidence and to reject a partial evidence pair. `e0bec865005e4e4b836fe76af66a6587d9f5743d` implements that fail-closed adapter. + +`404586a2eae752fa329dfc87768b22148ce9411a` adds the Rust-side process-handoff RED. `a0809cdee41100296e478c18653ab1e7f3305559` passes the retained byte count and SHA-256 only on the spawned analysis `Command`, first removing any inherited values so demo/manual jobs cannot accidentally consume ambient evidence. It does not call process-global `std::env::set_var`, so the two allowed in-flight jobs cannot overwrite each other's identity evidence. + +`cbaaf868f7fa6d1050b62eec109bdb54d69e07d0` adds the CLI RED proving that a native-admitted job must not run the earlier temporary `TemporalAnalyzer` pathname probe. `a1136c5270cfbd940d9e3e3cea7cc55b6ce1cdb9` skips that compatibility-only probe whenever native evidence is scoped, leaving the content-bound separator as the first production audio decode path. `e1b50929f7d112cf8fb417ede97c98af6a3c2b41` pins the child-process environment contract; `88beb62d7f1cad5fc141b73da0c7f75ec9d785fe` is formatting-only. ## Selected design -`ae1f568591c9b9901ef2331f91068a6e1f91d561` introduces the GUI-independent `revalidate_local_audio_bootstrap_for_analysis` adapter. It projects the retained `LocalAudioPublicationIdentity` through the existing Project Persistence source-reference ACL, reopens only the fixed app-owned artifact through the injected native opener, and reuses the existing bounded re-admission verifier. On success it refreshes only transient source path/extension/size fields; it does not create durable evidence. +The selected design is an identity-equivalent immutable snapshot rather than cross-platform descriptor inheritance. + +1. `start_analysis_job` obtains the project-keyed native `LocalAudioPublicationIdentity` and revalidates the current app-owned source through the existing no-follow/reparse-aware native opener. +2. The worker receives that same retained identity. `run_analysis_engine` removes inherited BandScope admission variables, then sets exact `file_size_bytes` and `content_sha256` only on that job's child `Command`. +3. The Python CLI skips the compatibility temporal pathname probe when either native evidence variable is present. A partial pair therefore reaches the separator and fails closed rather than silently falling back to an unverified decode. +4. `AudioStemSeparator.separate` validates the canonical evidence pair, opens the selected source once, checks descriptor size, copies exactly the expected number of bytes into a private `SpooledTemporaryFile` while hashing them, performs a one-byte growth probe, and compares SHA-256. +5. Only a matching snapshot is rewound and passed to the existing `decode_mono_audio` `BinaryIO` boundary. Later pathname replacement cannot change the encoded bytes consumed by decoder/MIR/model work for that analysis invocation. -`b84ed0e39d533ef5524d25c7d86bc0fcf0197d16` wires the adapter into the production `start_analysis_job` command. The command now obtains the project-keyed native publication identity, revalidates current bytes before filling `local_source`, and fails with `NotFound` plus the existing re-selection message when the native identity or current artifact cannot be re-established. `f5730fd0c237b02259cf25cf5570cbc0987a92c3` is a formatting/borrow-check-safe cleanup of the focused regression test; it does not alter the product contract. +This keeps BandScope audio truth in BandScope and reuses Resource Admission identity rather than adding a second digest owner. The environment variables are a per-process native-to-analysis capability envelope, not renderer API, durable project schema, provider configuration, or cross-service state. ## Rejected alternatives -**Trust restart verification for the lifetime of the open project.** Rejected because native bootstrap state is cached and can outlive later file mutation. +**Trust restart or dispatch verification until decode.** Rejected because CWE-367 describes exactly the failure mode where a resource can change between check and use. -**Let the renderer resubmit a digest immediately before analysis.** Rejected because renderer data is not Resource Admission authority and would recreate the source-evidence forgery path already removed from v3 Save. +**Let the renderer carry the digest into the analysis request.** Rejected because renderer data is not Resource Admission authority and would recreate the source-evidence forgery path removed from Project v3 Save. -**Rehash through a second analysis-specific implementation.** Rejected because the canonical bounded receipt verifier and source-reference ACL already exist. The dispatch adapter composes those contracts instead of creating another hash/file-size policy. +**Mutate the desktop process environment before spawning Python.** Rejected because `MAX_IN_FLIGHT_JOBS` permits concurrent jobs; process-global mutation would create a cross-job race. -**Claim strict byte continuity after the dispatch check.** Rejected because the child decoder still performs a later pathname open. The residual interval is smaller but non-zero. +**Pass only the transient pathname and rehash it independently in Python.** Rejected because it would duplicate the digest contract and still permit another pathname read after the check. + +**Require one OS descriptor inheritance mechanism across Windows and macOS immediately.** Rejected for this increment because platform handle inheritance semantics differ. The selected bounded snapshot is portable and ties decode bytes to the canonical native content identity without claiming that filesystem ancestry itself is descriptor-bound. ## Security Notes ### Attack surface and trust boundary -The renderer-visible project id is a selector only. Native `LocalAudioPublicationIdentityState` supplies the path-free expected identity, and native bootstrap state supplies the app-owned project root. The adapter requires both to name the same BandScope project and derives the fixed artifact from canonical identity fields. +The renderer-visible project id remains a selector only. Native `LocalAudioPublicationIdentityState` owns the expected content evidence. The Rust worker scopes that evidence to one analysis child. The Python process may see the transient app-owned pathname, but it cannot promote different bytes: size, exact bounded read, growth probe, and SHA-256 must all match before decode. ### Mitigations -The same no-follow/reparse-aware Project Persistence opener used for restart re-admission is invoked again immediately before queue admission. Exact size and SHA-256 are rechecked with the existing expected-length-plus-one-byte-growth bound. Failure occurs before `parsed_request.local_source` receives dispatch authority. +The repair combines two checks with different purposes. Native re-admission confirms the app-owned project/source contract immediately before queue admission. Python then creates a private content snapshot and verifies the same identity at the consuming decode boundary. The decoder reads the verified snapshot itself, eliminating the previous check-then-reopen byte gap. ### Safe failure -Missing retained identity, project mismatch, malformed identity, native open failure, growth, truncation, or same-size mutation returns `Analysis job source was not found. Choose local audio again.` and the job is not queued. Raw filesystem diagnostics do not cross into buyer-facing status. +Missing native identity, project mismatch, native re-open failure, malformed or partial child evidence, growth, truncation, or same-size mutation fails before separation/model work. Native paths and OS diagnostics are not returned as buyer-facing detail. Existing direct/manual library callers with no native evidence retain the compatibility path; production desktop local-audio jobs always provide evidence. ### Test points -`apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers exact-byte success and same-size mutation failure at the dispatch adapter. Existing restart re-admission tests remain canonical for malformed durable evidence, root substitution, no-follow/reparse behavior, growth, truncation, and exact SHA-256 identity. +- `apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers current-byte native revalidation and asserts per-child evidence scoping without process-global environment mutation. +- `services/analysis-engine/tests/test_audio_admitted_snapshot.py` covers same-size replacement rejection, verified-snapshot decode after pathname change, production `separate` evidence consumption, and partial-evidence rejection. +- `services/analysis-engine/tests/test_cli_native_admission_boundary.py` proves native-admitted jobs do not execute the legacy temporary pathname probe. +- Existing Project Persistence restart tests remain canonical for malformed durable evidence, root substitution, final-component no-follow/reparse behavior, growth, truncation, and exact SHA-256 identity. + +### Remaining risk and next causal work + +Content-byte continuity is now designed end to end for the production local-audio worker, but hosted exact-head GREEN and supported-platform real-audio acceptance are still required before this becomes release evidence. -### Remaining risk +The analysis/feature cache key currently uses project id, transient source path/name, and byte count rather than the retained content digest. Normal BandScope publication is no-clobber and project-specific, but the cache contract should still include the canonical digest so cache provenance is cryptographically tied to the same source identity and cannot depend on publication-history assumptions. -The verified descriptor is released before the Python analysis process opens `local_source.sourcePath`. A local replacement or mutation in that interval can therefore still create a TOCTOU gap. Release-grade byte continuity requires a descriptor/capability-bound decoder handoff or an equivalent supported-platform immutable-snapshot mechanism whose identity is retained through decode. Parent-directory descriptor binding and higher-ancestor replacement remain separate filesystem-authority work. +The Python path open is content-bound, not full filesystem-ancestry authority. A higher ancestor can still be replaced between native checks and Python open; different content fails the digest, but directory-handle-relative authority remains separate hardening if BandScope must prove that the bytes came from the same filesystem object rather than merely the same admitted content. + +`SpooledTemporaryFile` provides bounded, automatically cleaned temporary storage and may roll larger encoded sources to an OS-managed temporary file. That temporary-copy privacy/resource behavior needs supported Windows/macOS fault-injection and crash evidence before release. It is not a durable BandScope project artifact. ## Standards traceability -NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST has decided to revise the standard, but its current publication page still identifies FIPS 180-4; the announced revision has not superseded it. +MITRE CWE-367 defines the relevant weakness as checking resource state and then using a resource whose state can change before use. Its mitigation guidance notes that merely reducing the check/use interval does not remove the underlying identity problem. The selected snapshot instead verifies and then uses the same copied bytes. + +NIST FIPS 180-4 remains the published Secure Hash Standard defining SHA-256. NIST decided to revise FIPS 180-4, but the current NIST publication page still identifies FIPS 180-4 as the published standard; the announced revision has not superseded it. + +Python's `tempfile` documentation identifies `SpooledTemporaryFile` as a cross-platform high-level temporary-file interface with automatic cleanup and context-manager support. BandScope relies on those lifecycle semantics only for the transient snapshot; the cryptographic acceptance rule remains BandScope-owned. -NIST SP 800-218 v1.1 remains the released SSDF baseline. SP 800-218 Rev. 1 / SSDF 1.2 is still identified by NIST as an Initial Public Draft with the comment period closed on January 30, 2026. The repair follows the released SSDF principle of preventing recurrence by placing verification at the actual consuming boundary instead of relying on an earlier check. +NIST SP 800-218 v1.1 remains the released SSDF baseline. The repair follows its recurrence-prevention intent by moving verification to the actual consuming boundary instead of relying on a stale earlier check. ## References +MITRE. (2026). *CWE-367: Time-of-check time-of-use (TOCTOU) race condition* (CWE 4.20). https://cwe.mitre.org/data/definitions/367.html + National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (Federal Information Processing Standards Publication 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS).* https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs -Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 +Python Software Foundation. (2026). *tempfile — Generate temporary files and directories* (Python 3.14.7 documentation). https://docs.python.org/3/library/tempfile.html -Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd +Scarfone, K., Souppaya, M., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 From 19c2112fea48c55a17faf045c81447456cd370b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:23:40 +0900 Subject: [PATCH 392/448] test(audio): bind cache workspace to source identity --- .../src-tauri/tests/analysis_dispatch_revalidation.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs index 33a4cefbe..90c2e46e9 100644 --- a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -72,6 +72,14 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { .expect("unchanged app-owned bytes should regain dispatch authority"); assert_eq!(refreshed.source.source_path, source_path.to_string_lossy()); assert_eq!(refreshed.source.file_size_bytes, WAV_BYTES.len() as u64); + assert_eq!( + PathBuf::from(&refreshed.cache_root), + project_root.join("cache").join(WAV_SHA256) + ); + assert_eq!( + PathBuf::from(&refreshed.temp_root), + project_root.join("temp").join(WAV_SHA256) + ); let mut changed = WAV_BYTES.to_vec(); let last_byte = changed.len() - 1; From 063164e93b7ba9d93ec29648c9d2d8d1a203d488 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:24:00 +0900 Subject: [PATCH 393/448] fix(audio): scope analysis workspaces by source digest --- apps/desktop/src-tauri/src/analysis_source.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/analysis_source.rs b/apps/desktop/src-tauri/src/analysis_source.rs index fe16b8430..c70c426e3 100644 --- a/apps/desktop/src-tauri/src/analysis_source.rs +++ b/apps/desktop/src-tauri/src/analysis_source.rs @@ -15,12 +15,13 @@ const ANALYSIS_SOURCE_NOT_FOUND: &str = /// through the Project Persistence ACL, the fixed `source.` artifact /// is reopened by the supplied no-follow/reparse-aware native opener, and the /// current bytes must reproduce the retained bounded size and SHA-256 before -/// they can be sent to the analysis process. OS/file-system details are reduced -/// to the stable buyer-facing re-selection error. -/// -/// This narrows the restart-to-dispatch mutation window but does not claim -/// descriptor-to-decoder continuity: the analysis process still opens the -/// returned transient path after this function releases the verified reader. +/// they can be sent to the analysis process. Cache and temporary workspaces are +/// namespaced by that same canonical digest so a same-path/same-size replacement +/// cannot alias analysis or stem-work artifacts from another content identity. +/// OS/file-system details are reduced to the stable buyer-facing re-selection +/// error. Decoder-byte continuity is completed downstream by the per-process +/// identity handoff and verified snapshot; this adapter does not mint a second +/// content identity. pub fn revalidate_local_audio_bootstrap_for_analysis( bootstrap: &ProjectBootstrapSummaryPayload, identity: &LocalAudioPublicationIdentity, @@ -43,9 +44,18 @@ where ) .map_err(|_| ANALYSIS_SOURCE_NOT_FOUND.to_string())?; + let content_sha256 = reopened.identity.content_sha256.clone(); let mut refreshed = bootstrap.clone(); refreshed.source.source_path = reopened.source_path.to_string_lossy().into_owned(); refreshed.source.extension = reopened.identity.extension; refreshed.source.file_size_bytes = reopened.identity.file_size_bytes; + refreshed.cache_root = Path::new(&bootstrap.cache_root) + .join(&content_sha256) + .to_string_lossy() + .into_owned(); + refreshed.temp_root = Path::new(&bootstrap.temp_root) + .join(&content_sha256) + .to_string_lossy() + .into_owned(); Ok(refreshed) } From e67f9b2526c97c7ad0978c3810ad4b58aa3888da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:25:28 +0900 Subject: [PATCH 394/448] docs(audio): bind cache provenance to source digest --- .../analysis-dispatch-source-revalidation.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/traceability/analysis-dispatch-source-revalidation.md b/docs/traceability/analysis-dispatch-source-revalidation.md index 319eefd60..d588a373f 100644 --- a/docs/traceability/analysis-dispatch-source-revalidation.md +++ b/docs/traceability/analysis-dispatch-source-revalidation.md @@ -2,7 +2,7 @@ ## Problem -Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. The first dispatch repair repeated that proof immediately before queue admission, but then released the verified native reader. The Python analysis process subsequently reopened `local_source.sourcePath`, leaving a smaller TOCTOU window in which different bytes could reach decode after the native check. +Project v3 restart re-admission proves that the persisted `sourceReference` still matches the app-owned `source.` before native bootstrap authority is restored. The first dispatch repair repeated that proof immediately before queue admission, but then released the verified native reader. The Python analysis process subsequently reopened `local_source.sourcePath`, leaving a smaller TOCTOU window in which different bytes could reach decode after the native check. Separately, analysis/feature cache workspaces were keyed from project/path/name/size rather than the retained digest, so cache provenance still depended on the no-clobber publication invariant rather than the same content identity used for admission. ## Constraints @@ -10,6 +10,7 @@ Project v3 restart re-admission proves that the persisted `sourceReference` stil - Renderer IPC supplies only the BandScope project id for local audio. It cannot submit a path, byte count, digest, artifact name, `sourceReference`, or native-admission evidence. - Exact byte count and SHA-256 must survive the Rust-to-Python process boundary without process-global mutation because BandScope allows concurrent analysis jobs. - The Python decoder must consume the same verified byte snapshot, not a pathname reopened after verification. +- Analysis cache and temporary stem-work namespaces must also be derived from the retained content identity so same-path/same-size content cannot alias reusable evidence. - Operating-system path and I/O failures remain bounded; raw local paths and native diagnostics do not become buyer-facing errors. - Deterministic RIFF/WAVE byte strings in this lane are security/unit fixtures only. They are not MIR accuracy, decoder-quality, or production scientific acceptance evidence. @@ -25,17 +26,20 @@ Project v3 restart re-admission proves that the persisted `sourceReference` stil `cbaaf868f7fa6d1050b62eec109bdb54d69e07d0` adds the CLI RED proving that a native-admitted job must not run the earlier temporary `TemporalAnalyzer` pathname probe. `a1136c5270cfbd940d9e3e3cea7cc55b6ce1cdb9` skips that compatibility-only probe whenever native evidence is scoped, leaving the content-bound separator as the first production audio decode path. `e1b50929f7d112cf8fb417ede97c98af6a3c2b41` pins the child-process environment contract; `88beb62d7f1cad5fc141b73da0c7f75ec9d785fe` is formatting-only. +`19c2112fea48c55a17faf045c81447456cd370b5` adds a cache/temp provenance RED: a successfully revalidated source must receive cache and temporary work roots namespaced by the canonical SHA-256. `063164e93b7ba9d93ec29648c9d2d8d1a203d488` implements that in the native dispatch adapter, before the roots enter the Python request. Existing Python cache/stem-work keying therefore remains compatible while its parent namespace is content-bound. + ## Selected design The selected design is an identity-equivalent immutable snapshot rather than cross-platform descriptor inheritance. 1. `start_analysis_job` obtains the project-keyed native `LocalAudioPublicationIdentity` and revalidates the current app-owned source through the existing no-follow/reparse-aware native opener. -2. The worker receives that same retained identity. `run_analysis_engine` removes inherited BandScope admission variables, then sets exact `file_size_bytes` and `content_sha256` only on that job's child `Command`. -3. The Python CLI skips the compatibility temporal pathname probe when either native evidence variable is present. A partial pair therefore reaches the separator and fails closed rather than silently falling back to an unverified decode. -4. `AudioStemSeparator.separate` validates the canonical evidence pair, opens the selected source once, checks descriptor size, copies exactly the expected number of bytes into a private `SpooledTemporaryFile` while hashing them, performs a one-byte growth probe, and compares SHA-256. -5. Only a matching snapshot is rewound and passed to the existing `decode_mono_audio` `BinaryIO` boundary. Later pathname replacement cannot change the encoded bytes consumed by decoder/MIR/model work for that analysis invocation. +2. That revalidation also derives content-addressed cache/temp roots beneath the already app-owned project workspaces using the canonical SHA-256. +3. The worker receives the same retained identity. `run_analysis_engine` removes inherited BandScope admission variables, then sets exact `file_size_bytes` and `content_sha256` only on that job's child `Command`. +4. The Python CLI skips the compatibility temporal pathname probe when either native evidence variable is present. A partial pair therefore reaches the separator and fails closed rather than silently falling back to an unverified decode. +5. `AudioStemSeparator.separate` validates the canonical evidence pair, opens the selected source once, checks descriptor size, copies exactly the expected number of bytes into a private `SpooledTemporaryFile` while hashing them, performs a one-byte growth probe, and compares SHA-256. +6. Only a matching snapshot is rewound and passed to the existing `decode_mono_audio` `BinaryIO` boundary. Later pathname replacement cannot change the encoded bytes consumed by decoder/MIR/model work for that analysis invocation. -This keeps BandScope audio truth in BandScope and reuses Resource Admission identity rather than adding a second digest owner. The environment variables are a per-process native-to-analysis capability envelope, not renderer API, durable project schema, provider configuration, or cross-service state. +This keeps BandScope audio truth in BandScope and reuses Resource Admission identity rather than adding a second digest owner. The environment variables are a per-process native-to-analysis capability envelope, not renderer API, durable project schema, provider configuration, or cross-service state. Cache/temp scoping is native-derived and does not require Python to become a second owner of publication identity. ## Rejected alternatives @@ -47,17 +51,19 @@ This keeps BandScope audio truth in BandScope and reuses Resource Admission iden **Pass only the transient pathname and rehash it independently in Python.** Rejected because it would duplicate the digest contract and still permit another pathname read after the check. +**Key cache only by path and byte count.** Rejected because reproducible scientific evidence should not depend on the assumption that a pathname has never been rebound to same-size content. The canonical digest now namespaces cache and stem-work roots before Python sees them. + **Require one OS descriptor inheritance mechanism across Windows and macOS immediately.** Rejected for this increment because platform handle inheritance semantics differ. The selected bounded snapshot is portable and ties decode bytes to the canonical native content identity without claiming that filesystem ancestry itself is descriptor-bound. ## Security Notes ### Attack surface and trust boundary -The renderer-visible project id remains a selector only. Native `LocalAudioPublicationIdentityState` owns the expected content evidence. The Rust worker scopes that evidence to one analysis child. The Python process may see the transient app-owned pathname, but it cannot promote different bytes: size, exact bounded read, growth probe, and SHA-256 must all match before decode. +The renderer-visible project id remains a selector only. Native `LocalAudioPublicationIdentityState` owns the expected content evidence. The Rust worker scopes that evidence to one analysis child. The Python process may see the transient app-owned pathname, but it cannot promote different bytes: size, exact bounded read, growth probe, and SHA-256 must all match before decode. Reusable cache/temp artifacts are rooted beneath the same digest identity. ### Mitigations -The repair combines two checks with different purposes. Native re-admission confirms the app-owned project/source contract immediately before queue admission. Python then creates a private content snapshot and verifies the same identity at the consuming decode boundary. The decoder reads the verified snapshot itself, eliminating the previous check-then-reopen byte gap. +The repair combines checks with distinct purposes. Native re-admission confirms the app-owned project/source contract immediately before queue admission. Python then creates a private content snapshot and verifies the same identity at the consuming decode boundary. The decoder reads the verified snapshot itself, eliminating the previous check-then-reopen byte gap. Content-addressed work roots prevent same-path/same-size cache aliasing without duplicating hash computation in Python. ### Safe failure @@ -65,21 +71,21 @@ Missing native identity, project mismatch, native re-open failure, malformed or ### Test points -- `apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers current-byte native revalidation and asserts per-child evidence scoping without process-global environment mutation. +- `apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs` covers current-byte native revalidation, digest-scoped cache/temp roots, and per-child evidence scoping without process-global environment mutation. - `services/analysis-engine/tests/test_audio_admitted_snapshot.py` covers same-size replacement rejection, verified-snapshot decode after pathname change, production `separate` evidence consumption, and partial-evidence rejection. - `services/analysis-engine/tests/test_cli_native_admission_boundary.py` proves native-admitted jobs do not execute the legacy temporary pathname probe. - Existing Project Persistence restart tests remain canonical for malformed durable evidence, root substitution, final-component no-follow/reparse behavior, growth, truncation, and exact SHA-256 identity. ### Remaining risk and next causal work -Content-byte continuity is now designed end to end for the production local-audio worker, but hosted exact-head GREEN and supported-platform real-audio acceptance are still required before this becomes release evidence. - -The analysis/feature cache key currently uses project id, transient source path/name, and byte count rather than the retained content digest. Normal BandScope publication is no-clobber and project-specific, but the cache contract should still include the canonical digest so cache provenance is cryptographically tied to the same source identity and cannot depend on publication-history assumptions. +Content-byte continuity and cache namespace identity are now designed end to end for the production local-audio worker, but hosted exact-head GREEN and supported-platform real-audio acceptance are still required before this becomes release evidence. The Python path open is content-bound, not full filesystem-ancestry authority. A higher ancestor can still be replaced between native checks and Python open; different content fails the digest, but directory-handle-relative authority remains separate hardening if BandScope must prove that the bytes came from the same filesystem object rather than merely the same admitted content. `SpooledTemporaryFile` provides bounded, automatically cleaned temporary storage and may roll larger encoded sources to an OS-managed temporary file. That temporary-copy privacy/resource behavior needs supported Windows/macOS fault-injection and crash evidence before release. It is not a durable BandScope project artifact. +The next product-causal consumer remains #1160: only after fresh full-mix decode/playback authority exists may persisted `selectedPlaybackSource` be reconciled with currently admitted stems, with missing preferred stems falling back to Full mix. + ## Standards traceability MITRE CWE-367 defines the relevant weakness as checking resource state and then using a resource whose state can change before use. Its mitigation guidance notes that merely reducing the check/use interval does not remove the underlying identity problem. The selected snapshot instead verifies and then uses the same copied bytes. From 716438d1c927bbdea38cb6a78b3a417994992e3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:41:48 +0900 Subject: [PATCH 395/448] test(audio): reject uncached Demucs remote lookup --- .../tests/test_demucs_local_model_boundary.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 services/analysis-engine/tests/test_demucs_local_model_boundary.py diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py new file mode 100644 index 000000000..4e37da059 --- /dev/null +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -0,0 +1,89 @@ +"""Regression contracts for local-only Demucs model admission.""" + +from __future__ import annotations + +import sys +from types import ModuleType, SimpleNamespace + +import pytest + +from bandscope_analysis.separation.audio_separator import AudioStemSeparator + + +class _FakeModel: + """Minimal Demucs model stand-in for the model-loading boundary.""" + + sources = ["drums", "bass", "other", "vocals"] + + def eval(self) -> "_FakeModel": + """Match the model evaluation call used after admission.""" + return self + + +def _install_fake_runtime( + monkeypatch: pytest.MonkeyPatch, + *, + torch_hub_dir: str, + get_model: object, +) -> None: + """Install deterministic torch/Demucs import boundaries for local-model tests.""" + fake_torch = ModuleType("torch") + fake_torch.hub = SimpleNamespace(get_dir=lambda: torch_hub_dir) # type: ignore[attr-defined] + + demucs_module = ModuleType("demucs") + pretrained_module = ModuleType("demucs.pretrained") + pretrained_module.get_model = get_model # type: ignore[attr-defined] + demucs_module.pretrained = pretrained_module # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setitem(sys.modules, "demucs", demucs_module) + monkeypatch.setitem(sys.modules, "demucs.pretrained", pretrained_module) + + +def test_demucs_model_load_fails_closed_before_remote_lookup_when_checkpoint_missing( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep first-run local analysis from turning into a model network download.""" + calls = {"count": 0} + + def forbidden_remote_lookup(_name: str) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("remote Demucs lookup must not run without a local checkpoint") + + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_remote_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + AudioStemSeparator()._load_model() + + assert calls["count"] == 0 + + +def test_demucs_model_load_allows_exact_cached_htdemucs_checkpoint( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve offline use when the canonical checkpoint is already present.""" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / "955717e8-8726e21a.th").write_bytes(b"cached-checkpoint-fixture") + calls: list[str] = [] + + def fake_local_lookup(name: str) -> _FakeModel: + calls.append(name) + return _FakeModel() + + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=fake_local_lookup, + ) + + model = AudioStemSeparator()._load_model() + + assert isinstance(model, _FakeModel) + assert calls == ["htdemucs"] From 61b629baaef0d6da15967fe272b9d9f109d18eaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:44:12 +0900 Subject: [PATCH 396/448] fix(audio): fail closed before Demucs model download --- .../separation/audio_separator.py | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 040b7d222..e25261406 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -1,4 +1,4 @@ -"""Local audio source separation using a bundled Demucs model. +"""Local audio source separation using a local Demucs model. Replaces the previous FFT band-masking heuristic — which scored around -39 dB SI-SDR on a realistic mix (i.e. not real separation) — with Demucs (htdemucs), a @@ -20,11 +20,11 @@ closed instead of being silently truncated or normalized. - Empty, non-finite, or float32-overflowed model stems fail closed before they can become successful silence or downstream rehearsal evidence. -- Inference runs locally with no network access. Accelerator outputs cross back - to CPU before NumPy conversion so configured device execution cannot fail at - the device/host boundary. The model weights are loaded from the local Demucs - cache or a configured bundled path; offline weight bundling is tracked in the - supplemental component inventory. +- Inference does not intentionally acquire model weights from the network. The + canonical htdemucs checkpoint must already exist as a regular file in the + local torch checkpoint cache before Demucs's resolver is entered. Release + bundling and full artifact provenance remain supply-chain work rather than a + hidden first-run download. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -60,10 +60,14 @@ _EMPTY_RANGE_EPS = 1e-9 _MODEL_OUTPUT_ERROR = "Stem separation produced invalid audio." _ADMITTED_SOURCE_CHANGED_ERROR = "Stem separation source changed before decode." +_LOCAL_MODEL_UNAVAILABLE_ERROR = "Stem separation model weights are not installed locally." _ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" _ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" _SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 _COPY_CHUNK_BYTES = 64 * 1024 +_DEMUCS_LOCAL_CHECKPOINTS = { + "htdemucs": "955717e8-8726e21a.th", +} def _contains_parent_path_segment(path: Path) -> bool: @@ -109,6 +113,29 @@ def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: return expected_size, digest +def _local_demucs_checkpoint(model_name: str) -> Path | None: + """Return the exact already-cached checkpoint accepted for offline inference. + + Demucs's default ``get_model`` path uses ``RemoteRepo`` and delegates missing + checkpoints to ``torch.hub.load_state_dict_from_url``. BandScope therefore + checks the canonical cache object before entering that resolver. Unsupported + model names, missing files, directories, and symlinks fail closed instead of + turning local analysis into an implicit network operation. + """ + checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) + if checkpoint_name is None: + return None + try: + import torch + + checkpoint_path = Path(torch.hub.get_dir()) / "checkpoints" / checkpoint_name + if checkpoint_path.is_symlink() or not checkpoint_path.is_file(): + return None + except (ImportError, OSError, TypeError, ValueError): + return None + return checkpoint_path + + @dataclass(frozen=True) class AudioSeparationConfig: """Resource and model settings for local stem separation.""" @@ -220,15 +247,14 @@ def _separate_signal( return {name: _as_float_array(sources[name]) for name in _STEM_ORDER} def _load_model(self) -> Any: - """Lazily load and cache the Demucs model. + """Lazily load the canonical Demucs model without an implicit download. Demucs (and torch) are installed only on platforms with current torch wheels (see pyproject platform markers); elsewhere separation fails with a - clear error the pipeline already surfaces safely. - - The first load fetches model weights, whose download progress torch may - print to stdout — that would corrupt the CLI's JSON stdout protocol, so - stdout is redirected to stderr while the model is obtained. + clear error the pipeline already surfaces safely. The upstream resolver + is entered only when the exact canonical checkpoint already exists as a + regular local cache file. A missing checkpoint therefore fails closed + instead of becoming a first-run network dependency. """ if self._model is None: try: @@ -240,6 +266,9 @@ def _load_model(self) -> Any: "Stem separation is not available on this platform (demucs/torch not installed)" ) from error + if _local_demucs_checkpoint(self.config.model_name) is None: + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) + with contextlib.redirect_stdout(sys.stderr): model = get_model(self.config.model_name) model.eval() From 8ccdf2013582db16811168cfadd44d1560ed375d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:45:47 +0900 Subject: [PATCH 397/448] test(audio): preserve mocked Demucs unit boundary --- services/analysis-engine/tests/conftest.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/services/analysis-engine/tests/conftest.py b/services/analysis-engine/tests/conftest.py index e926e1e91..826284efc 100644 --- a/services/analysis-engine/tests/conftest.py +++ b/services/analysis-engine/tests/conftest.py @@ -27,3 +27,27 @@ def make_symlink_or_skip(link: Path, target: Path, *, target_is_directory: bool link.symlink_to(target, target_is_directory=target_is_directory) except OSError as error: pytest.skip(f"symlink creation is unavailable in this environment: {error}") + + +@pytest.fixture(autouse=True) +def _preserve_mocked_demucs_unit_boundary( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +) -> None: + """Let separation unit tests keep their explicit fake-model boundary. + + ``test_separation.py`` replaces Demucs itself with an in-memory fake so its + signal/shape contracts do not depend on a heavyweight checkpoint. The + production local-model admission guard is covered separately by + ``test_demucs_local_model_boundary.py`` and must not be bypassed there. + """ + if request.path.name != "test_separation.py": + return + + from bandscope_analysis.separation import audio_separator + + monkeypatch.setattr( + audio_separator, + "_local_demucs_checkpoint", + lambda _model_name: Path("mocked-demucs-checkpoint"), + ) From b91d12015cf330be7fdbea394166ab9c66c12396 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:47:54 +0900 Subject: [PATCH 398/448] docs(audio): trace local-only Demucs model admission --- .../demucs-local-model-admission.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/traceability/demucs-local-model-admission.md diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md new file mode 100644 index 000000000..bde42e565 --- /dev/null +++ b/docs/traceability/demucs-local-model-admission.md @@ -0,0 +1,123 @@ +# Demucs local-model admission traceability + +Status: Draft + +## Problem + +BandScope promises local-first rehearsal analysis and the repository security policy says ordinary local analysis must not acquire a network dependency. The production separator nevertheless called `demucs.pretrained.get_model("htdemucs")` without first proving that the canonical checkpoint already existed locally. + +For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=None)` constructs a `RemoteRepo`. `RemoteRepo.get_model` delegates to `torch.hub.load_state_dict_from_url`, so an absent checkpoint can turn the first stem-separation run into an implicit network download. The same production module previously described inference as network-free, so documentation and runtime behavior disagreed. + +## Constraints + +- BandScope must remain local-first during ordinary analysis. +- Runtime code must not silently download model artifacts. +- Model artifacts are supply-chain inputs: redistribution rights, provenance, full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than to MIR inference code. +- A missing local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and the actual released model artifact. + +## RED evidence + +Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds `test_demucs_local_model_boundary.py`. + +The regression replaces `demucs.pretrained.get_model` with a forbidden remote resolver and points torch at an empty hub directory. The predecessor enters `get_model` and therefore violates the contract. The same test also records the intended compatibility path: an exact cached `955717e8-8726e21a.th` checkpoint may enter the existing Demucs resolver. + +No hosted RED failure receipt is claimed because the causal fix followed immediately on the same owner branch. + +## Selected repair + +Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` adds a narrow model-admission guard before Demucs resolution: + +- only the production `htdemucs` model has a registered local checkpoint filename; +- the expected checkpoint must already exist under torch's local `checkpoints` cache; +- the checkpoint must be a regular file and not a symlink; +- unsupported model names and missing/non-regular checkpoint objects fail with the bounded message `Stem separation model weights are not installed locally.`; +- only after that evidence exists does BandScope enter the upstream Demucs resolver. + +Commit `8ccdf2013582db16811168cfadd44d1560ed375d` keeps the older separation unit tests honest about their scope: those tests deliberately replace Demucs with an in-memory fake and therefore bypass only the local-checkpoint prerequisite. The dedicated model-admission regressions do not receive that bypass. + +## Alternatives considered + +### Keep the existing `get_model("htdemucs")` path + +Rejected. An absent checkpoint can invoke `torch.hub.load_state_dict_from_url`; that contradicts the repository's local-first runtime rule and makes first-run behavior depend on external availability. + +### Download the model explicitly from BandScope at first use + +Rejected for ordinary analysis. This merely moves the hidden network dependency into BandScope and would require an explicit model-delivery product flow, source allowlist, full checksum/signature verification, license review, disclosure, cancellation/retry semantics and updater-style rollback. + +### Bundle the checkpoint immediately in this Project Persistence PR + +Rejected as a cross-context shortcut. Shipping a large model artifact changes licensing, package size, SBOM/supplemental inventory, signing, notarization, update and rollback evidence. Distribution must own that immutable artifact contract; Signal/MIR consumes only the released artifact. + +### Fall back to heuristic FFT stem masks + +Rejected. The prior heuristic is not a scientifically acceptable substitute for source separation and must not turn an unavailable model into false rehearsal confidence. + +## Security Notes + +### Attack surface + +The model-loading boundary crosses the local Python process into third-party Demucs/torch model resolution and deserialization. The checkpoint path and bytes are security- and scientific-integrity-sensitive inputs. + +### Trust boundary + +Signal/MIR may consume a locally available model artifact, but it does not own remote download policy or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. + +### Realistic threats + +- a first stem-separation run initiates an unexpected network request because weights are absent; +- an unsupported model name expands the remote model surface; +- a symlink is presented at the expected checkpoint path; +- a missing model is silently replaced with weaker heuristic output; +- a locally present checkpoint is corrupt or malicious even though its filename is expected; +- the checkpoint is removed between BandScope's local preflight and the upstream resolver, allowing the upstream remote fallback to become reachable in that race window. + +### Mitigations + +- exact allowlist for the currently supported `htdemucs` cached checkpoint filename; +- regular-file and no-symlink preflight; +- bounded fail-closed error before entering Demucs when local evidence is absent; +- no heuristic-success fallback; +- dedicated regression proving the missing-checkpoint path never invokes the upstream resolver; +- release model bundling is kept as a separate Distribution prerequisite rather than being implemented as an ad-hoc download in MIR code. + +### Remaining risk + +The current repair is an immediate local-first guard, not the final release artifact boundary. It does not provide a repository-owned full SHA-256/signature for the Demucs weights, and the existing supplemental component inventory does not list a shipped htdemucs checkpoint. There is also a small preflight-to-upstream-resolver TOCTOU window: if the checkpoint disappears after the guard, upstream `get_model` can still attempt its remote path. Release readiness therefore requires a BandScope-owned immutable model artifact or a local-only loader that consumes an already-open/verified artifact without any remote fallback. + +The upstream `955717e8-8726e21a.th` filename contains only the truncated checksum convention used by Demucs/torch. That is not sufficient evidence for BandScope's commercial release provenance claim. + +### Test points + +- absent local checkpoint: upstream resolver call count remains zero; +- exact cached checkpoint: existing offline resolver remains usable; +- unsupported model name: fail closed without lookup; +- symlink/non-regular checkpoint: fail closed; +- released model artifact: full checksum/signature, inventory, package and offline Windows/macOS real-audio acceptance before release. + +## Effect + +The normal missing-model path no longer begins an implicit model download. A machine without the required local checkpoint now receives a bounded separation-unavailable failure instead of silently becoming network-dependent. + +This deliberately exposes the next buyer-visible gap: a commercial BandScope package must provide an admitted model artifact so an offline buyer does not need a pre-populated developer torch cache. + +## Follow-up + +1. Establish the Distribution-owned htdemucs artifact decision: redistribution/license basis, exact version, full digest, storage/package location and release/update policy. +2. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to the verified released artifact, eliminating the remaining remote-fallback race. +3. Add the released model to `supply-chain/supplemental-component-inventory.json` and SBOM/provenance evidence. +4. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, with source-separation metrics and explicit uncertainty/claim boundaries. +5. Keep #770 as the scientific-accuracy owner; model-delivery evidence must not substitute for MIR-quality evidence. + +## References + +Défossez, A., Usunier, N., Bottou, L., & Bach, F. (2021). Music source separation in the waveform domain. *Transactions of the International Society for Music Information Retrieval, 4*(1), 197–208. https://doi.org/10.5334/tismir.76 + +Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid transformers for music source separation. *Proceedings of the IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)*. https://doi.org/10.1109/ICASSP49357.2023.10097003 + +Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/pretrained.py + +Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/repo.py + +Meta Platforms, Inc. (2023). Demucs remote model manifest. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/remote/files.txt From fb9571b5bb351ccb742a5956dbfa82966400b02d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:02:25 +0900 Subject: [PATCH 399/448] test(audio): reject tampered cached Demucs checkpoint --- .../tests/test_demucs_local_model_boundary.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 4e37da059..b17bf4c1a 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -2,11 +2,13 @@ from __future__ import annotations +import hashlib import sys from types import ModuleType, SimpleNamespace import pytest +import bandscope_analysis.separation.audio_separator as audio_separator_module from bandscope_analysis.separation.audio_separator import AudioStemSeparator @@ -87,3 +89,37 @@ def fake_local_lookup(name: str) -> _FakeModel: assert isinstance(model, _FakeModel) assert calls == ["htdemucs"] + + +def test_demucs_model_load_rejects_tampered_cached_checkpoint( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject cached bytes that do not match the checkpoint filename checksum.""" + trusted_bytes = b"trusted-checkpoint-fixture" + checksum_prefix = hashlib.sha256(trusted_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(b"tampered-checkpoint-fixture") + calls = {"count": 0} + + def forbidden_lookup(_name: str) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("tampered checkpoint must not reach Demucs deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + AudioStemSeparator()._load_model() + + assert calls["count"] == 0 From d0432187eea6ec94a247d78f1c02f69e7185a5a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:06:36 +0900 Subject: [PATCH 400/448] fix(audio): verify cached Demucs checkpoint checksum --- .../separation/audio_separator.py | 47 +++++++++++++++---- .../tests/test_demucs_local_model_boundary.py | 14 ++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index e25261406..c17857e93 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -22,9 +22,10 @@ can become successful silence or downstream rehearsal evidence. - Inference does not intentionally acquire model weights from the network. The canonical htdemucs checkpoint must already exist as a regular file in the - local torch checkpoint cache before Demucs's resolver is entered. Release - bundling and full artifact provenance remain supply-chain work rather than a - hidden first-run download. + local torch checkpoint cache and reproduce the checksum prefix encoded in its + canonical filename before Demucs's resolver is entered. Release bundling and + full artifact provenance remain supply-chain work rather than a hidden + first-run download. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -90,6 +91,21 @@ def _valid_sha256_hex(value: object) -> bool: ) +def _checkpoint_checksum_prefix(checkpoint_name: str) -> str | None: + """Return the canonical Demucs checksum prefix encoded in a checkpoint name.""" + stem = Path(checkpoint_name).stem + if "-" not in stem: + return None + _signature, checksum_prefix = stem.rsplit("-", 1) + if ( + len(checksum_prefix) != 8 + or checksum_prefix != checksum_prefix.lower() + or any(character not in "0123456789abcdef" for character in checksum_prefix) + ): + return None + return checksum_prefix + + def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: """Read the native-owned evidence scoped to one analysis process. @@ -118,12 +134,16 @@ def _local_demucs_checkpoint(model_name: str) -> Path | None: Demucs's default ``get_model`` path uses ``RemoteRepo`` and delegates missing checkpoints to ``torch.hub.load_state_dict_from_url``. BandScope therefore - checks the canonical cache object before entering that resolver. Unsupported - model names, missing files, directories, and symlinks fail closed instead of - turning local analysis into an implicit network operation. + admits only the canonical regular cache object whose streamed SHA-256 matches + the checksum prefix encoded by Demucs in that checkpoint filename. Unsupported + names, missing/non-regular objects, symlinks, and modified bytes fail closed + before the upstream resolver can deserialize or remotely replace the model. """ checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) - if checkpoint_name is None: + checksum_prefix = ( + _checkpoint_checksum_prefix(checkpoint_name) if checkpoint_name is not None else None + ) + if checkpoint_name is None or checksum_prefix is None: return None try: import torch @@ -131,6 +151,12 @@ def _local_demucs_checkpoint(model_name: str) -> Path | None: checkpoint_path = Path(torch.hub.get_dir()) / "checkpoints" / checkpoint_name if checkpoint_path.is_symlink() or not checkpoint_path.is_file(): return None + digest = hashlib.sha256() + with checkpoint_path.open("rb") as checkpoint_file: + while chunk := checkpoint_file.read(_COPY_CHUNK_BYTES): + digest.update(chunk) + if not digest.hexdigest().startswith(checksum_prefix): + return None except (ImportError, OSError, TypeError, ValueError): return None return checkpoint_path @@ -252,9 +278,10 @@ def _load_model(self) -> Any: Demucs (and torch) are installed only on platforms with current torch wheels (see pyproject platform markers); elsewhere separation fails with a clear error the pipeline already surfaces safely. The upstream resolver - is entered only when the exact canonical checkpoint already exists as a - regular local cache file. A missing checkpoint therefore fails closed - instead of becoming a first-run network dependency. + is entered only when the canonical checkpoint is already present as a + regular local cache file and matches its encoded checksum prefix. A + missing or modified checkpoint therefore fails closed instead of becoming + a first-run network dependency or unverified deserialization input. """ if self._model is None: try: diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index b17bf4c1a..1fc125bf7 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -65,20 +65,28 @@ def forbidden_remote_lookup(_name: str) -> _FakeModel: assert calls["count"] == 0 -def test_demucs_model_load_allows_exact_cached_htdemucs_checkpoint( +def test_demucs_model_load_allows_checksum_matching_cached_htdemucs_checkpoint( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Preserve offline use when the canonical checkpoint is already present.""" + """Preserve offline use when cached bytes match the registered checkpoint checksum.""" + checkpoint_bytes = b"cached-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" checkpoint_root = tmp_path / "torch-hub" / "checkpoints" checkpoint_root.mkdir(parents=True) - (checkpoint_root / "955717e8-8726e21a.th").write_bytes(b"cached-checkpoint-fixture") + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) calls: list[str] = [] def fake_local_lookup(name: str) -> _FakeModel: calls.append(name) return _FakeModel() + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) _install_fake_runtime( monkeypatch, torch_hub_dir=str(tmp_path / "torch-hub"), From e2b87211a7762a7a092c3251f47c8497183c360a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:07:53 +0900 Subject: [PATCH 401/448] docs(audio): trace Demucs cache integrity admission --- .../demucs-local-model-admission.md | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index bde42e565..d704225b7 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -8,25 +8,26 @@ BandScope promises local-first rehearsal analysis and the repository security po For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=None)` constructs a `RemoteRepo`. `RemoteRepo.get_model` delegates to `torch.hub.load_state_dict_from_url`, so an absent checkpoint can turn the first stem-separation run into an implicit network download. The same production module previously described inference as network-free, so documentation and runtime behavior disagreed. +The first local-only guard then exposed a second integrity gap: it accepted any regular non-symlink file named `955717e8-8726e21a.th`. Demucs itself treats the suffix after `-` as a SHA-256 checksum prefix for locally stored model files. Accepting modified bytes solely because the expected filename remained present allowed a tampered cache object to reach model deserialization. + ## Constraints - BandScope must remain local-first during ordinary analysis. - Runtime code must not silently download model artifacts. +- A locally cached model must reproduce the checksum convention attached to the canonical Demucs checkpoint before upstream model resolution. - Model artifacts are supply-chain inputs: redistribution rights, provenance, full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than to MIR inference code. -- A missing local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- A missing or modified local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. - Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and the actual released model artifact. ## RED evidence -Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds `test_demucs_local_model_boundary.py`. - -The regression replaces `demucs.pretrained.get_model` with a forbidden remote resolver and points torch at an empty hub directory. The predecessor enters `get_model` and therefore violates the contract. The same test also records the intended compatibility path: an exact cached `955717e8-8726e21a.th` checkpoint may enter the existing Demucs resolver. +Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds the initial `test_demucs_local_model_boundary.py` contract. It replaces `demucs.pretrained.get_model` with a forbidden remote resolver and points torch at an empty hub directory. The predecessor enters `get_model` and therefore violates the local-first contract. No hosted RED failure receipt is claimed because the causal fix followed immediately on the same owner branch. -No hosted RED failure receipt is claimed because the causal fix followed immediately on the same owner branch. +Commit `fb9571b5bb351ccb742a5956dbfa82966400b02d` adds the cache-integrity RED. The fixture registers a checkpoint name whose checksum suffix belongs to one byte sequence, writes different bytes under that exact name, and requires the upstream resolver call count to remain zero. The predecessor checked only path shape, regular-file status and filename, so it would enter the fake resolver. An immediate ordinary descendant carried the fix; no hosted RED failure receipt is claimed for this intermediate head. ## Selected repair -Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` adds a narrow model-admission guard before Demucs resolution: +Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` adds the first narrow model-admission guard before Demucs resolution: - only the production `htdemucs` model has a registered local checkpoint filename; - the expected checkpoint must already exist under torch's local `checkpoints` cache; @@ -34,6 +35,8 @@ Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` adds a narrow model-admission - unsupported model names and missing/non-regular checkpoint objects fail with the bounded message `Stem separation model weights are not installed locally.`; - only after that evidence exists does BandScope enter the upstream Demucs resolver. +Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` closes the filename-only cache-integrity gap. BandScope now parses the canonical lowercase eight-hex checksum suffix from the registered Demucs checkpoint filename, streams SHA-256 over the local object in bounded chunks, and enters `get_model` only when the digest starts with that expected prefix. A modified cache object therefore fails before Demucs/torch deserialization or remote fallback is entered. The compatibility regression uses fixture-specific registered checksum prefixes so unit bytes do not masquerade as the real released htdemucs artifact. + Commit `8ccdf2013582db16811168cfadd44d1560ed375d` keeps the older separation unit tests honest about their scope: those tests deliberately replace Demucs with an in-memory fake and therefore bypass only the local-checkpoint prerequisite. The dedicated model-admission regressions do not receive that bypass. ## Alternatives considered @@ -42,6 +45,10 @@ Commit `8ccdf2013582db16811168cfadd44d1560ed375d` keeps the older separation uni Rejected. An absent checkpoint can invoke `torch.hub.load_state_dict_from_url`; that contradicts the repository's local-first runtime rule and makes first-run behavior depend on external availability. +### Trust the canonical checkpoint filename without checking bytes + +Rejected. Demucs's own local repository logic interprets a suffix such as `-8726e21a` as a SHA-256 prefix and checks local model bytes before loading. Filename-only admission would be weaker than the upstream local-model integrity convention while still crossing a deserialization boundary. + ### Download the model explicitly from BandScope at first use Rejected for ordinary analysis. This merely moves the hidden network dependency into BandScope and would require an explicit model-delivery product flow, source allowlist, full checksum/signature verification, license review, disclosure, cancellation/retry semantics and updater-style rollback. @@ -62,7 +69,7 @@ The model-loading boundary crosses the local Python process into third-party Dem ### Trust boundary -Signal/MIR may consume a locally available model artifact, but it does not own remote download policy or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. +Signal/MIR may consume a locally available model artifact, but it does not own remote download policy or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. The checksum prefix is a bounded compatibility integrity check, not BandScope's commercial provenance authority. ### Realistic threats @@ -70,42 +77,45 @@ Signal/MIR may consume a locally available model artifact, but it does not own r - an unsupported model name expands the remote model surface; - a symlink is presented at the expected checkpoint path; - a missing model is silently replaced with weaker heuristic output; -- a locally present checkpoint is corrupt or malicious even though its filename is expected; -- the checkpoint is removed between BandScope's local preflight and the upstream resolver, allowing the upstream remote fallback to become reachable in that race window. +- modified or malicious bytes are placed at the expected checkpoint filename; +- the checkpoint is removed or replaced between BandScope's local verification and the upstream resolver, allowing the upstream remote fallback or a different local object to become reachable in that race window. ### Mitigations - exact allowlist for the currently supported `htdemucs` cached checkpoint filename; - regular-file and no-symlink preflight; -- bounded fail-closed error before entering Demucs when local evidence is absent; +- strict parsing of the registered eight-hex lowercase checksum suffix; +- bounded streaming SHA-256 verification against that Demucs checksum prefix before upstream resolution; +- bounded fail-closed error before entering Demucs when local evidence is absent or modified; - no heuristic-success fallback; -- dedicated regression proving the missing-checkpoint path never invokes the upstream resolver; -- release model bundling is kept as a separate Distribution prerequisite rather than being implemented as an ad-hoc download in MIR code. +- dedicated regressions proving missing and checksum-mismatched checkpoints never invoke the upstream resolver; +- release model bundling remains a separate Distribution prerequisite rather than an ad-hoc download in MIR code. ### Remaining risk -The current repair is an immediate local-first guard, not the final release artifact boundary. It does not provide a repository-owned full SHA-256/signature for the Demucs weights, and the existing supplemental component inventory does not list a shipped htdemucs checkpoint. There is also a small preflight-to-upstream-resolver TOCTOU window: if the checkpoint disappears after the guard, upstream `get_model` can still attempt its remote path. Release readiness therefore requires a BandScope-owned immutable model artifact or a local-only loader that consumes an already-open/verified artifact without any remote fallback. +The current repair is an immediate local-first compatibility guard, not the final release artifact boundary. The eight-hex suffix is only a truncated upstream checksum convention; it is not a repository-owned full SHA-256, signature or immutable release provenance statement. The existing supplemental component inventory also does not list a shipped htdemucs checkpoint. -The upstream `955717e8-8726e21a.th` filename contains only the truncated checksum convention used by Demucs/torch. That is not sufficient evidence for BandScope's commercial release provenance claim. +There is still a preflight-to-upstream-resolver TOCTOU window: BandScope closes its verification descriptor before `get_model` reopens the cache path. If the checkpoint disappears or is replaced after verification, the upstream remote path can become reachable or another object can be presented. Release readiness therefore requires a BandScope-owned immutable model artifact and a local-only loader that consumes the verified artifact without any remote fallback or pathname re-open race. ### Test points - absent local checkpoint: upstream resolver call count remains zero; -- exact cached checkpoint: existing offline resolver remains usable; +- checksum-mismatched cached checkpoint: upstream resolver call count remains zero; +- checksum-matching registered fixture: existing offline resolver remains usable; - unsupported model name: fail closed without lookup; - symlink/non-regular checkpoint: fail closed; - released model artifact: full checksum/signature, inventory, package and offline Windows/macOS real-audio acceptance before release. ## Effect -The normal missing-model path no longer begins an implicit model download. A machine without the required local checkpoint now receives a bounded separation-unavailable failure instead of silently becoming network-dependent. +The normal missing-model path no longer begins an implicit model download, and a modified cache object with the expected filename no longer reaches Demucs model resolution solely by name. A machine without a locally admitted checkpoint receives a bounded separation-unavailable failure instead of silently becoming network-dependent or deserializing unchecked cached bytes. This deliberately exposes the next buyer-visible gap: a commercial BandScope package must provide an admitted model artifact so an offline buyer does not need a pre-populated developer torch cache. ## Follow-up 1. Establish the Distribution-owned htdemucs artifact decision: redistribution/license basis, exact version, full digest, storage/package location and release/update policy. -2. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to the verified released artifact, eliminating the remaining remote-fallback race. +2. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to an already-open or immutable verified released artifact, eliminating the remaining remote-fallback/path-reopen race. 3. Add the released model to `supply-chain/supplemental-component-inventory.json` and SBOM/provenance evidence. 4. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, with source-separation metrics and explicit uncertainty/claim boundaries. 5. Keep #770 as the scientific-accuracy owner; model-delivery evidence must not substitute for MIR-quality evidence. @@ -121,3 +131,5 @@ Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *fa Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/repo.py Meta Platforms, Inc. (2023). Demucs remote model manifest. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/remote/files.txt + +PyTorch Contributors. (2026). `torch.hub`: model download and cache behavior. *pytorch/pytorch*. https://github.com/pytorch/pytorch/blob/main/torch/hub.py From 58f30d43e0f1df0a2b9be864e8bf08b20f5b7da9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:17:07 +0900 Subject: [PATCH 402/448] docs(audio): block commercial Demucs weight packaging --- .../demucs-local-model-admission.md | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index d704225b7..8abf8b1ce 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -10,14 +10,17 @@ For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=Non The first local-only guard then exposed a second integrity gap: it accepted any regular non-symlink file named `955717e8-8726e21a.th`. Demucs itself treats the suffix after `-` as a SHA-256 checksum prefix for locally stored model files. Accepting modified bytes solely because the expected filename remained present allowed a tampered cache object to reach model deserialization. +A fresh commercial review exposed a separate rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical cache integrity, local-only loading, a third-party mirror, or conversion of the same weights cannot create commercial rights. BandScope issue #1181 owns that release blocker. + ## Constraints - BandScope must remain local-first during ordinary analysis. - Runtime code must not silently download model artifacts. - A locally cached model must reproduce the checksum convention attached to the canonical Demucs checkpoint before upstream model resolution. -- Model artifacts are supply-chain inputs: redistribution rights, provenance, full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than to MIR inference code. -- A missing or modified local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. -- Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and the actual released model artifact. +- The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. +- Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than to MIR inference code. +- A missing, modified, or commercially inadmissible local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. ## RED evidence @@ -39,6 +42,8 @@ Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` closes the filename-only cache Commit `8ccdf2013582db16811168cfadd44d1560ed375d` keeps the older separation unit tests honest about their scope: those tests deliberately replace Demucs with an in-memory fake and therefore bypass only the local-checkpoint prerequisite. The dedicated model-admission regressions do not receive that bypass. +The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking external legal/product prerequisite. Signal/MIR may continue to keep its local fail-closed technical boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. + ## Alternatives considered ### Keep the existing `get_model("htdemucs")` path @@ -51,11 +56,19 @@ Rejected. Demucs's own local repository logic interprets a suffix such as `-8726 ### Download the model explicitly from BandScope at first use -Rejected for ordinary analysis. This merely moves the hidden network dependency into BandScope and would require an explicit model-delivery product flow, source allowlist, full checksum/signature verification, license review, disclosure, cancellation/retry semantics and updater-style rollback. +Rejected for ordinary analysis. This merely moves the hidden network dependency into BandScope. More importantly, the upstream maintainer's stated scientific-purpose restriction means an explicit downloader does not cure the commercial-rights defect. Any future model-delivery flow also requires source allowlisting, full checksum/signature verification, rights review, disclosure, cancellation/retry semantics and updater-style rollback. ### Bundle the checkpoint immediately in this Project Persistence PR -Rejected as a cross-context shortcut. Shipping a large model artifact changes licensing, package size, SBOM/supplemental inventory, signing, notarization, update and rollback evidence. Distribution must own that immutable artifact contract; Signal/MIR consumes only the released artifact. +Rejected. This is both a cross-context shortcut and currently incompatible with commercial release. Shipping the upstream pretrained weights changes rights exposure, package size, SBOM/supplemental inventory, signing, notarization, update and rollback evidence. Distribution may only own an immutable model artifact after #1181's rights prerequisite is satisfied or an admissible replacement is selected. + +### Rely on a third-party rehost or converted copy carrying an MIT label + +Rejected. The upstream maintainer explicitly distinguished the model weights from the MIT-licensed code. A mirror, conversion, or downstream metadata label does not establish rights broader than the upstream grant. + +### Replace the model with a commercially admissible separator + +Viable and now a first-class commercial alternative. The replacement must have traceable model-weight/training-data rights and must meet BandScope's real-audio separation/rehearsal accuracy contract; license safety cannot be bought by silently regressing to weak separation. ### Fall back to heuristic FFT stem masks @@ -65,11 +78,11 @@ Rejected. The prior heuristic is not a scientifically acceptable substitute for ### Attack surface -The model-loading boundary crosses the local Python process into third-party Demucs/torch model resolution and deserialization. The checkpoint path and bytes are security- and scientific-integrity-sensitive inputs. +The model-loading boundary crosses the local Python process into third-party Demucs/torch model resolution and deserialization. The checkpoint path and bytes are security- and scientific-integrity-sensitive inputs. A release model artifact also crosses a legal/supply-chain trust boundary before it can become a commercially supported dependency. ### Trust boundary -Signal/MIR may consume a locally available model artifact, but it does not own remote download policy or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. The checksum prefix is a bounded compatibility integrity check, not BandScope's commercial provenance authority. +Signal/MIR may consume a locally available technically admitted model artifact, but it does not own remote download policy, commercial-use/redistribution rights, or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. The checksum prefix is a bounded compatibility integrity check, not BandScope's commercial provenance or rights authority. #1181 owns the pretrained-weight commercial-rights blocker. ### Realistic threats @@ -78,7 +91,9 @@ Signal/MIR may consume a locally available model artifact, but it does not own r - a symlink is presented at the expected checkpoint path; - a missing model is silently replaced with weaker heuristic output; - modified or malicious bytes are placed at the expected checkpoint filename; -- the checkpoint is removed or replaced between BandScope's local verification and the upstream resolver, allowing the upstream remote fallback or a different local object to become reachable in that race window. +- the checkpoint is removed or replaced between BandScope's local verification and the upstream resolver, allowing the upstream remote fallback or a different local object to become reachable in that race window; +- a technically valid upstream pretrained checkpoint is shipped or advertised commercially despite the maintainer's scientific-purpose restriction; +- a third-party mirror or converted artifact is mistaken for a new commercial license grant. ### Mitigations @@ -89,13 +104,16 @@ Signal/MIR may consume a locally available model artifact, but it does not own r - bounded fail-closed error before entering Demucs when local evidence is absent or modified; - no heuristic-success fallback; - dedicated regressions proving missing and checksum-mismatched checkpoints never invoke the upstream resolver; -- release model bundling remains a separate Distribution prerequisite rather than an ad-hoc download in MIR code. +- #1181 blocks commercial packaging/auto-download/rights claims for the upstream pretrained weights until explicit rights or an admissible replacement exists; +- release model packaging remains a separate Distribution prerequisite rather than an ad-hoc download in MIR code. ### Remaining risk The current repair is an immediate local-first compatibility guard, not the final release artifact boundary. The eight-hex suffix is only a truncated upstream checksum convention; it is not a repository-owned full SHA-256, signature or immutable release provenance statement. The existing supplemental component inventory also does not list a shipped htdemucs checkpoint. -There is still a preflight-to-upstream-resolver TOCTOU window: BandScope closes its verification descriptor before `get_model` reopens the cache path. If the checkpoint disappears or is replaced after verification, the upstream remote path can become reachable or another object can be presented. Release readiness therefore requires a BandScope-owned immutable model artifact and a local-only loader that consumes the verified artifact without any remote fallback or pathname re-open race. +There is still a preflight-to-upstream-resolver TOCTOU window: BandScope closes its verification descriptor before `get_model` reopens the cache path. If the checkpoint disappears or is replaced after verification, the upstream remote path can become reachable or another object can be presented. A future technically admissible model therefore needs a local-only loader that consumes an already-open or immutable verified artifact without remote fallback or pathname re-open race. + +The upstream pretrained `htdemucs` weights are additionally blocked for commercial release by #1181 unless an explicit grant is obtained. Even a perfect local-only loader and full digest would not resolve that rights constraint. ### Test points @@ -104,21 +122,23 @@ There is still a preflight-to-upstream-resolver TOCTOU window: BandScope closes - checksum-matching registered fixture: existing offline resolver remains usable; - unsupported model name: fail closed without lookup; - symlink/non-regular checkpoint: fail closed; -- released model artifact: full checksum/signature, inventory, package and offline Windows/macOS real-audio acceptance before release. +- commercial release: exact model rights evidence is present and linked to the immutable artifact, or the upstream weights are absent from release inputs; +- released admissible model artifact: full checksum/signature, inventory, package and offline Windows/macOS real-audio acceptance before release. ## Effect The normal missing-model path no longer begins an implicit model download, and a modified cache object with the expected filename no longer reaches Demucs model resolution solely by name. A machine without a locally admitted checkpoint receives a bounded separation-unavailable failure instead of silently becoming network-dependent or deserializing unchecked cached bytes. -This deliberately exposes the next buyer-visible gap: a commercial BandScope package must provide an admitted model artifact so an offline buyer does not need a pre-populated developer torch cache. +The next buyer-visible gap is no longer just "bundle htdemucs for offline use." The upstream pretrained weights are not commercially releasable on the currently documented basis. BandScope must obtain explicit rights or select/train a commercially admissible model, then bind that artifact to the local-only integrity/provenance boundary without regressing real-audio rehearsal quality. ## Follow-up -1. Establish the Distribution-owned htdemucs artifact decision: redistribution/license basis, exact version, full digest, storage/package location and release/update policy. -2. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to an already-open or immutable verified released artifact, eliminating the remaining remote-fallback/path-reopen race. -3. Add the released model to `supply-chain/supplemental-component-inventory.json` and SBOM/provenance evidence. -4. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, with source-separation metrics and explicit uncertainty/claim boundaries. -5. Keep #770 as the scientific-accuracy owner; model-delivery evidence must not substitute for MIR-quality evidence. +1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact pretrained weights or select/train a commercially admissible replacement with traceable training-data/model rights. +2. For the admissible model, establish exact version, full digest/signature, storage/package location and release/update/rollback policy under Distribution. +3. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to an already-open or immutable verified admissible artifact, eliminating remote-fallback/path-reopen behavior. +4. Add the released model to `supply-chain/supplemental-component-inventory.json` and SBOM/provenance/NOTICE evidence as applicable. +5. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics and explicit uncertainty/claim boundaries. +6. Keep #770 as the scientific-accuracy owner; model-delivery/licensing evidence must not substitute for MIR-quality evidence. ## References @@ -126,6 +146,8 @@ Défossez, A., Usunier, N., Bottou, L., & Bach, F. (2021). Music source separati Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid transformers for music source separation. *Proceedings of the IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)*. https://doi.org/10.1109/ICASSP49357.2023.10097003 +Défossez, A. (2022). Re: License of pre-trained models (Issue comment 1134828611). *facebookresearch/demucs* (Issue #327). https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611 + Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/pretrained.py Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/repo.py From 9fd9b562d068dea1e9348584f53ced6d9c6c0553 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:03:08 +0900 Subject: [PATCH 403/448] test(separation): require immutable local Demucs snapshot --- .../tests/test_demucs_local_model_boundary.py | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 1fc125bf7..8a88f4cb8 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -4,6 +4,7 @@ import hashlib import sys +from pathlib import Path from types import ModuleType, SimpleNamespace import pytest @@ -49,7 +50,7 @@ def test_demucs_model_load_fails_closed_before_remote_lookup_when_checkpoint_mis """Keep first-run local analysis from turning into a model network download.""" calls = {"count": 0} - def forbidden_remote_lookup(_name: str) -> _FakeModel: + def forbidden_remote_lookup(_name: str, **_kwargs: object) -> _FakeModel: calls["count"] += 1 raise AssertionError("remote Demucs lookup must not run without a local checkpoint") @@ -65,21 +66,29 @@ def forbidden_remote_lookup(_name: str) -> _FakeModel: assert calls["count"] == 0 -def test_demucs_model_load_allows_checksum_matching_cached_htdemucs_checkpoint( +def test_demucs_model_load_uses_verified_private_snapshot_in_local_repo( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Preserve offline use when cached bytes match the registered checkpoint checksum.""" + """Deserialize the verified bytes, not a later replacement of the cache pathname.""" checkpoint_bytes = b"cached-checkpoint-fixture" checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] checkpoint_name = f"955717e8-{checksum_prefix}.th" checkpoint_root = tmp_path / "torch-hub" / "checkpoints" checkpoint_root.mkdir(parents=True) - (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) - calls: list[str] = [] - - def fake_local_lookup(name: str) -> _FakeModel: - calls.append(name) + checkpoint_path = checkpoint_root / checkpoint_name + checkpoint_path.write_bytes(checkpoint_bytes) + calls: list[tuple[str, Path]] = [] + + def fake_local_lookup(name: str, *, repo: Path | None = None) -> _FakeModel: + assert name == "955717e8" + assert repo is not None + snapshot_path = repo / checkpoint_name + assert snapshot_path.read_bytes() == checkpoint_bytes + + checkpoint_path.write_bytes(b"cache-path-replaced-after-snapshot") + assert snapshot_path.read_bytes() == checkpoint_bytes + calls.append((name, repo)) return _FakeModel() monkeypatch.setattr( @@ -96,7 +105,9 @@ def fake_local_lookup(name: str) -> _FakeModel: model = AudioStemSeparator()._load_model() assert isinstance(model, _FakeModel) - assert calls == ["htdemucs"] + assert len(calls) == 1 + assert calls[0][0] == "955717e8" + assert not calls[0][1].exists() def test_demucs_model_load_rejects_tampered_cached_checkpoint( @@ -112,7 +123,7 @@ def test_demucs_model_load_rejects_tampered_cached_checkpoint( (checkpoint_root / checkpoint_name).write_bytes(b"tampered-checkpoint-fixture") calls = {"count": 0} - def forbidden_lookup(_name: str) -> _FakeModel: + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: calls["count"] += 1 raise AssertionError("tampered checkpoint must not reach Demucs deserialization") From 3662de13e1ffae2ac2337835dd6f317011e81bff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:04:39 +0900 Subject: [PATCH 404/448] fix(separation): bind Demucs load to verified local snapshot --- .../separation/audio_separator.py | 119 +++++++++++------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c17857e93..346a01e52 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -23,9 +23,11 @@ - Inference does not intentionally acquire model weights from the network. The canonical htdemucs checkpoint must already exist as a regular file in the local torch checkpoint cache and reproduce the checksum prefix encoded in its - canonical filename before Demucs's resolver is entered. Release bundling and - full artifact provenance remain supply-chain work rather than a hidden - first-run download. + canonical filename. BandScope copies the verified descriptor bytes into a + private temporary local Demucs repository and resolves the checkpoint by its + signature there, so upstream deserialization cannot reopen or download from + the mutable cache pathname. Release bundling, full digest/signature provenance, + and model-rights evidence remain Distribution work. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -37,6 +39,7 @@ import hashlib import logging import os +import stat import sys import tempfile from dataclasses import dataclass @@ -91,19 +94,20 @@ def _valid_sha256_hex(value: object) -> bool: ) -def _checkpoint_checksum_prefix(checkpoint_name: str) -> str | None: - """Return the canonical Demucs checksum prefix encoded in a checkpoint name.""" +def _checkpoint_signature_and_checksum(checkpoint_name: str) -> tuple[str, str] | None: + """Return the canonical Demucs signature/checksum encoded in a checkpoint name.""" stem = Path(checkpoint_name).stem if "-" not in stem: return None - _signature, checksum_prefix = stem.rsplit("-", 1) - if ( - len(checksum_prefix) != 8 - or checksum_prefix != checksum_prefix.lower() - or any(character not in "0123456789abcdef" for character in checksum_prefix) - ): - return None - return checksum_prefix + signature, checksum_prefix = stem.rsplit("-", 1) + for value in (signature, checksum_prefix): + if ( + len(value) != 8 + or value != value.lower() + or any(character not in "0123456789abcdef" for character in value) + ): + return None + return signature, checksum_prefix def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: @@ -129,37 +133,63 @@ def _admitted_audio_evidence_from_environment() -> tuple[int, str] | None: return expected_size, digest -def _local_demucs_checkpoint(model_name: str) -> Path | None: - """Return the exact already-cached checkpoint accepted for offline inference. +def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> str | None: + """Copy one verified cache descriptor into a private local Demucs repository. - Demucs's default ``get_model`` path uses ``RemoteRepo`` and delegates missing - checkpoints to ``torch.hub.load_state_dict_from_url``. BandScope therefore - admits only the canonical regular cache object whose streamed SHA-256 matches - the checksum prefix encoded by Demucs in that checkpoint filename. Unsupported - names, missing/non-regular objects, symlinks, and modified bytes fail closed - before the upstream resolver can deserialize or remotely replace the model. + The mutable torch cache pathname is used only to acquire the source descriptor. + The descriptor must represent the same regular file observed by ``lstat``; + ``O_NOFOLLOW`` is requested where the host exposes it. The bytes copied from + that descriptor must reproduce the checksum prefix encoded in the canonical + checkpoint filename. Demucs later deserializes only the private snapshot. """ checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) - checksum_prefix = ( - _checkpoint_checksum_prefix(checkpoint_name) if checkpoint_name is not None else None + identity = ( + _checkpoint_signature_and_checksum(checkpoint_name) if checkpoint_name is not None else None ) - if checkpoint_name is None or checksum_prefix is None: + if checkpoint_name is None or identity is None: return None + signature, checksum_prefix = identity + + snapshot_path = snapshot_root / checkpoint_name try: import torch checkpoint_path = Path(torch.hub.get_dir()) / "checkpoints" / checkpoint_name - if checkpoint_path.is_symlink() or not checkpoint_path.is_file(): + path_stat = os.lstat(checkpoint_path) + if not stat.S_ISREG(path_stat.st_mode): return None - digest = hashlib.sha256() - with checkpoint_path.open("rb") as checkpoint_file: - while chunk := checkpoint_file.read(_COPY_CHUNK_BYTES): - digest.update(chunk) + + open_flags = os.O_RDONLY + open_flags |= getattr(os, "O_CLOEXEC", 0) + open_flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(checkpoint_path, open_flags) + try: + descriptor_stat = os.fstat(descriptor) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or descriptor_stat.st_dev != path_stat.st_dev + or descriptor_stat.st_ino != path_stat.st_ino + ): + return None + + digest = hashlib.sha256() + with os.fdopen(descriptor, "rb", closefd=False) as checkpoint_file: + with snapshot_path.open("xb") as snapshot_file: + while chunk := checkpoint_file.read(_COPY_CHUNK_BYTES): + digest.update(chunk) + snapshot_file.write(chunk) + snapshot_file.flush() + os.fsync(snapshot_file.fileno()) + finally: + os.close(descriptor) + if not digest.hexdigest().startswith(checksum_prefix): + snapshot_path.unlink(missing_ok=True) return None except (ImportError, OSError, TypeError, ValueError): + snapshot_path.unlink(missing_ok=True) return None - return checkpoint_path + return signature @dataclass(frozen=True) @@ -273,15 +303,14 @@ def _separate_signal( return {name: _as_float_array(sources[name]) for name in _STEM_ORDER} def _load_model(self) -> Any: - """Lazily load the canonical Demucs model without an implicit download. + """Lazily load the canonical Demucs model from one verified local snapshot. Demucs (and torch) are installed only on platforms with current torch wheels (see pyproject platform markers); elsewhere separation fails with a - clear error the pipeline already surfaces safely. The upstream resolver - is entered only when the canonical checkpoint is already present as a - regular local cache file and matches its encoded checksum prefix. A - missing or modified checkpoint therefore fails closed instead of becoming - a first-run network dependency or unverified deserialization input. + clear error the pipeline already surfaces safely. The canonical cache + checkpoint is copied from its verified descriptor into a private local + repository. Passing that repository explicitly keeps Demucs on LocalRepo + and prevents RemoteRepo/network fallback or a second open of the cache path. """ if self._model is None: try: @@ -293,13 +322,19 @@ def _load_model(self) -> Any: "Stem separation is not available on this platform (demucs/torch not installed)" ) from error - if _local_demucs_checkpoint(self.config.model_name) is None: - raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) + with tempfile.TemporaryDirectory(prefix="bandscope-demucs-model-") as snapshot_dir: + snapshot_root = Path(snapshot_dir) + model_signature = _snapshot_local_demucs_checkpoint( + self.config.model_name, + snapshot_root, + ) + if model_signature is None: + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) - with contextlib.redirect_stdout(sys.stderr): - model = get_model(self.config.model_name) - model.eval() - self._model = model + with contextlib.redirect_stdout(sys.stderr): + model = get_model(model_signature, repo=snapshot_root) + model.eval() + self._model = model return self._model def _apply_model(self, model: Any, audio: AudioStemArray) -> dict[str, np.ndarray[Any, Any]]: From 7ac4bc1d35ff736966ed556407b6ff56d03942c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:14:50 +0900 Subject: [PATCH 405/448] test(separation): bound local Demucs snapshot size --- .../tests/test_demucs_local_model_boundary.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 8a88f4cb8..0369ea8d5 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -142,3 +142,43 @@ def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: AudioStemSeparator()._load_model() assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_checkpoint_over_resource_limit_before_resolver( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject an oversized cache object before copying or deserializing it.""" + checkpoint_bytes = b"oversized-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("oversized checkpoint must not reach Demucs deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setattr( + audio_separator_module, + "_MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES", + len(checkpoint_bytes) - 1, + raising=False, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + AudioStemSeparator()._load_model() + + assert calls["count"] == 0 From c21c6c4476f7c9ae937a24dda77eb841515ed315 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:16:45 +0900 Subject: [PATCH 406/448] fix(separation): bound Demucs checkpoint snapshot --- .../separation/audio_separator.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 346a01e52..4bb09e268 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -21,9 +21,9 @@ - Empty, non-finite, or float32-overflowed model stems fail closed before they can become successful silence or downstream rehearsal evidence. - Inference does not intentionally acquire model weights from the network. The - canonical htdemucs checkpoint must already exist as a regular file in the - local torch checkpoint cache and reproduce the checksum prefix encoded in its - canonical filename. BandScope copies the verified descriptor bytes into a + canonical htdemucs checkpoint must already exist as a bounded regular file in + the local torch checkpoint cache and reproduce the checksum prefix encoded in + its canonical filename. BandScope copies the verified descriptor bytes into a private temporary local Demucs repository and resolves the checkpoint by its signature there, so upstream deserialization cannot reopen or download from the mutable cache pathname. Release bundling, full digest/signature provenance, @@ -69,6 +69,7 @@ _ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" _SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 _COPY_CHUNK_BYTES = 64 * 1024 +_MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES = 128 * 1024 * 1024 _DEMUCS_LOCAL_CHECKPOINTS = { "htdemucs": "955717e8-8726e21a.th", } @@ -138,9 +139,11 @@ def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> s The mutable torch cache pathname is used only to acquire the source descriptor. The descriptor must represent the same regular file observed by ``lstat``; - ``O_NOFOLLOW`` is requested where the host exposes it. The bytes copied from - that descriptor must reproduce the checksum prefix encoded in the canonical - checkpoint filename. Demucs later deserializes only the private snapshot. + ``O_NOFOLLOW`` is requested where the host exposes it. The admitted object is + size-bounded before and during the copy so cache corruption cannot consume + unbounded snapshot storage. The copied bytes must reproduce the checksum + prefix encoded in the canonical checkpoint filename. Demucs later + deserializes only the private snapshot. """ checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) identity = ( @@ -169,13 +172,19 @@ def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> s not stat.S_ISREG(descriptor_stat.st_mode) or descriptor_stat.st_dev != path_stat.st_dev or descriptor_stat.st_ino != path_stat.st_ino + or descriptor_stat.st_size <= 0 + or descriptor_stat.st_size > _MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES ): return None digest = hashlib.sha256() + copied_bytes = 0 with os.fdopen(descriptor, "rb", closefd=False) as checkpoint_file: with snapshot_path.open("xb") as snapshot_file: while chunk := checkpoint_file.read(_COPY_CHUNK_BYTES): + copied_bytes += len(chunk) + if copied_bytes > _MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES: + raise ValueError("local Demucs checkpoint exceeds resource limit") digest.update(chunk) snapshot_file.write(chunk) snapshot_file.flush() From cce563827ddf29f1351af9dac0d61771beda7fd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:19:40 +0900 Subject: [PATCH 407/448] docs(traceability): record bounded Demucs snapshot --- .../demucs-local-model-admission.md | 147 +++++++++--------- 1 file changed, 75 insertions(+), 72 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index 8abf8b1ce..4c4f90d38 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -4,141 +4,148 @@ Status: Draft ## Problem -BandScope promises local-first rehearsal analysis and the repository security policy says ordinary local analysis must not acquire a network dependency. The production separator nevertheless called `demucs.pretrained.get_model("htdemucs")` without first proving that the canonical checkpoint already existed locally. +BandScope promises local-first rehearsal analysis and the repository security policy says ordinary local analysis must not acquire a network dependency. The production separator originally called `demucs.pretrained.get_model("htdemucs")` without first proving that the canonical checkpoint already existed locally. -For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=None)` constructs a `RemoteRepo`. `RemoteRepo.get_model` delegates to `torch.hub.load_state_dict_from_url`, so an absent checkpoint can turn the first stem-separation run into an implicit network download. The same production module previously described inference as network-free, so documentation and runtime behavior disagreed. +For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=None)` constructs a `RemoteRepo`. `RemoteRepo.get_model` can delegate to `torch.hub.load_state_dict_from_url`, so an absent checkpoint could turn first stem separation into an implicit network download. A first local-only guard then exposed a second integrity gap: it accepted any regular non-symlink file named `955717e8-8726e21a.th`. Demucs itself treats the suffix after `-` as a SHA-256 checksum prefix for locally stored model files, so filename-only admission was weaker than upstream's own local repository contract. -The first local-only guard then exposed a second integrity gap: it accepted any regular non-symlink file named `955717e8-8726e21a.th`. Demucs itself treats the suffix after `-` as a SHA-256 checksum prefix for locally stored model files. Accepting modified bytes solely because the expected filename remained present allowed a tampered cache object to reach model deserialization. +The next implementation still verified the mutable torch-cache object and then let the upstream resolver reopen that pathname. A replacement or deletion after verification could therefore invalidate the evidence. The current implementation instead copies bytes from the verified descriptor into a private local Demucs repository and calls `get_model(signature, repo=snapshot_root)`. Demucs consequently resolves through `LocalRepo`; a later mutation of the torch-cache pathname cannot change the model bytes being deserialized or reactivate `RemoteRepo` for that load. -A fresh commercial review exposed a separate rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical cache integrity, local-only loading, a third-party mirror, or conversion of the same weights cannot create commercial rights. BandScope issue #1181 owns that release blocker. +That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. The current boundary rejects empty or over-limit descriptors before copying and enforces the same ceiling while streaming, covering growth after `fstat` as well. + +A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, or conversion of the same weights cannot create commercial rights. BandScope issue #1181 owns that release blocker. ## Constraints - BandScope must remain local-first during ordinary analysis. - Runtime code must not silently download model artifacts. -- A locally cached model must reproduce the checksum convention attached to the canonical Demucs checkpoint before upstream model resolution. +- A local model cache object is untrusted input: type, identity, byte size, and checksum evidence must be bounded before deserialization. +- The private compatibility snapshot is temporary runtime authority, not a released model artifact or provenance statement. - The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. -- Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than to MIR inference code. -- A missing, modified, or commercially inadmissible local model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, exact full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than MIR inference code. +- A missing, modified, oversized, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. - Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. ## RED evidence -Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds the initial `test_demucs_local_model_boundary.py` contract. It replaces `demucs.pretrained.get_model` with a forbidden remote resolver and points torch at an empty hub directory. The predecessor enters `get_model` and therefore violates the local-first contract. No hosted RED failure receipt is claimed because the causal fix followed immediately on the same owner branch. +Commit `716438d1c927bbdea38cb6a78b3a417994992e3d` adds the initial local-only regression. It replaces the upstream resolver with a forbidden call and points torch at an empty hub directory. The predecessor enters `get_model`; the causal fix followed immediately, so no hosted RED failure receipt is claimed. + +Commit `fb9571b5bb351ccb742a5956dbfa82966400b02d` adds the cache-integrity RED. The fixture registers a checkpoint name whose checksum suffix belongs to one byte sequence, writes different bytes under that exact name, and requires the resolver call count to remain zero. The predecessor checked only path shape, file type, and filename. + +Commit `9fd9b562d068dea1e9348584f53ced6d9c6c0553` adds the immutable-snapshot regression. It requires the bytes presented through the private local repository to remain the verified bytes even if the original torch-cache pathname is replaced after snapshot acquisition. -Commit `fb9571b5bb351ccb742a5956dbfa82966400b02d` adds the cache-integrity RED. The fixture registers a checkpoint name whose checksum suffix belongs to one byte sequence, writes different bytes under that exact name, and requires the upstream resolver call count to remain zero. The predecessor checked only path shape, regular-file status and filename, so it would enter the fake resolver. An immediate ordinary descendant carried the fix; no hosted RED failure receipt is claimed for this intermediate head. +Commit `7ac4bc1d35ff736966ed556407b6ff56d03942c0` adds the resource-bound RED. A checksum-valid fixture is deliberately larger than a monkeypatched local-model ceiling; the Demucs resolver is forbidden. The predecessor had no checkpoint-size admission rule, so it would continue to resolution rather than fail before model loading. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for this intermediate head. ## Selected repair -Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` adds the first narrow model-admission guard before Demucs resolution: +Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` established the first narrow admission guard: the production `htdemucs` checkpoint must already exist locally, be a regular non-symlink object, and unsupported/missing inputs fail with the bounded message `Stem separation model weights are not installed locally.` -- only the production `htdemucs` model has a registered local checkpoint filename; -- the expected checkpoint must already exist under torch's local `checkpoints` cache; -- the checkpoint must be a regular file and not a symlink; -- unsupported model names and missing/non-regular checkpoint objects fail with the bounded message `Stem separation model weights are not installed locally.`; -- only after that evidence exists does BandScope enter the upstream Demucs resolver. +Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` parses the canonical lowercase eight-hex checksum suffix and streams SHA-256 over the local object before model resolution. The compatibility regression uses fixture-specific checksum prefixes so unit bytes do not masquerade as the released htdemucs artifact. -Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` closes the filename-only cache-integrity gap. BandScope now parses the canonical lowercase eight-hex checksum suffix from the registered Demucs checkpoint filename, streams SHA-256 over the local object in bounded chunks, and enters `get_model` only when the digest starts with that expected prefix. A modified cache object therefore fails before Demucs/torch deserialization or remote fallback is entered. The compatibility regression uses fixture-specific registered checksum prefixes so unit bytes do not masquerade as the real released htdemucs artifact. +Commit `3662de13e1ffae2ac2337835dd6f317011e81bff` closes the mutable-cache pathname gap. BandScope opens the canonical cache object with no-follow semantics where available, verifies that the opened descriptor is the same regular object observed by `lstat`, copies and hashes that descriptor into a process-private temporary Demucs repository, and invokes `get_model(signature, repo=snapshot_root)`. Upstream `get_model` therefore uses `LocalRepo`; the mutable torch-cache pathname is no longer reopened by the model resolver and `RemoteRepo` is not selected for this load. -Commit `8ccdf2013582db16811168cfadd44d1560ed375d` keeps the older separation unit tests honest about their scope: those tests deliberately replace Demucs with an in-memory fake and therefore bypass only the local-checkpoint prerequisite. The dedicated model-admission regressions do not receive that bypass. +Commit `c21c6c4476f7c9ae937a24dda77eb841515ed315` bounds that compatibility snapshot to 128 MiB. The descriptor must report a positive size no greater than the ceiling before copying. The copy loop also counts actual bytes and fails before writing an over-limit chunk, so growth after the metadata check cannot cause unbounded snapshot storage. A checksum mismatch, size violation, file-identity mismatch, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. -The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking external legal/product prerequisite. Signal/MIR may continue to keep its local fail-closed technical boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. +The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must eventually replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, package placement, and update/rollback compatibility are release inputs. + +The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking legal/product prerequisite. Signal/MIR may keep this technical fail-closed boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. ## Alternatives considered -### Keep the existing `get_model("htdemucs")` path +### Keep `get_model("htdemucs")` with no explicit local repository + +Rejected. An absent checkpoint can select `RemoteRepo`, and a mutable cache pathname can be reopened after BandScope's own verification. -Rejected. An absent checkpoint can invoke `torch.hub.load_state_dict_from_url`; that contradicts the repository's local-first runtime rule and makes first-run behavior depend on external availability. +### Trust the canonical filename without checking bytes -### Trust the canonical checkpoint filename without checking bytes +Rejected. Upstream `LocalRepo` interprets the checksum-bearing filename as integrity evidence. Filename-only admission is insufficient across a deserialization boundary. -Rejected. Demucs's own local repository logic interprets a suffix such as `-8726e21a` as a SHA-256 prefix and checks local model bytes before loading. Filename-only admission would be weaker than the upstream local-model integrity convention while still crossing a deserialization boundary. +### Verify the cache and then let upstream reopen it -### Download the model explicitly from BandScope at first use +Rejected. It leaves a verification-to-use pathname race. Copying from the verified descriptor into a private repository binds the bytes used by the resolver to the bytes BandScope admitted. -Rejected for ordinary analysis. This merely moves the hidden network dependency into BandScope. More importantly, the upstream maintainer's stated scientific-purpose restriction means an explicit downloader does not cure the commercial-rights defect. Any future model-delivery flow also requires source allowlisting, full checksum/signature verification, rights review, disclosure, cancellation/retry semantics and updater-style rollback. +### Copy any sized regular checkpoint and reject only after hashing -### Bundle the checkpoint immediately in this Project Persistence PR +Rejected. Integrity failure after an unbounded copy is still a resource-exhaustion path. Model artifacts require an explicit byte ceiling before and during materialization. -Rejected. This is both a cross-context shortcut and currently incompatible with commercial release. Shipping the upstream pretrained weights changes rights exposure, package size, SBOM/supplemental inventory, signing, notarization, update and rollback evidence. Distribution may only own an immutable model artifact after #1181's rights prerequisite is satisfied or an admissible replacement is selected. +### Download or bundle the checkpoint from this MIR/Project Persistence lane + +Rejected. Ordinary analysis must not gain a network dependency, and model acquisition/package provenance belongs to Distribution. More importantly, #1181 currently prevents treating the upstream pretrained weights as a commercially admissible BandScope release input. ### Rely on a third-party rehost or converted copy carrying an MIT label -Rejected. The upstream maintainer explicitly distinguished the model weights from the MIT-licensed code. A mirror, conversion, or downstream metadata label does not establish rights broader than the upstream grant. +Rejected. The upstream maintainer explicitly distinguished model weights from MIT-licensed source code. A mirror, conversion, or downstream label does not establish broader rights. ### Replace the model with a commercially admissible separator -Viable and now a first-class commercial alternative. The replacement must have traceable model-weight/training-data rights and must meet BandScope's real-audio separation/rehearsal accuracy contract; license safety cannot be bought by silently regressing to weak separation. +Viable. The replacement must have traceable model-weight/training-data rights and meet BandScope's real-audio source-separation and rehearsal-quality contract. License safety must not silently regress to heuristic stems. -### Fall back to heuristic FFT stem masks +### Fall back to heuristic FFT masks -Rejected. The prior heuristic is not a scientifically acceptable substitute for source separation and must not turn an unavailable model into false rehearsal confidence. +Rejected. The retired heuristic is not a scientifically acceptable substitute for source separation and must not turn unavailable model authority into false rehearsal confidence. ## Security Notes ### Attack surface -The model-loading boundary crosses the local Python process into third-party Demucs/torch model resolution and deserialization. The checkpoint path and bytes are security- and scientific-integrity-sensitive inputs. A release model artifact also crosses a legal/supply-chain trust boundary before it can become a commercially supported dependency. +The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, temporary snapshots, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. ### Trust boundary -Signal/MIR may consume a locally available technically admitted model artifact, but it does not own remote download policy, commercial-use/redistribution rights, or release packaging. The upstream Demucs resolver is not itself evidence that BandScope has admitted a release artifact. The checksum prefix is a bounded compatibility integrity check, not BandScope's commercial provenance or rights authority. #1181 owns the pretrained-weight commercial-rights blocker. +Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. ### Realistic threats -- a first stem-separation run initiates an unexpected network request because weights are absent; -- an unsupported model name expands the remote model surface; -- a symlink is presented at the expected checkpoint path; -- a missing model is silently replaced with weaker heuristic output; -- modified or malicious bytes are placed at the expected checkpoint filename; -- the checkpoint is removed or replaced between BandScope's local verification and the upstream resolver, allowing the upstream remote fallback or a different local object to become reachable in that race window; -- a technically valid upstream pretrained checkpoint is shipped or advertised commercially despite the maintainer's scientific-purpose restriction; +- an absent model initiates an unexpected network fetch; +- an unsupported model name expands the resolver surface; +- a symlink/non-regular object is presented under the expected cache pathname; +- modified bytes retain a trusted-looking checkpoint filename; +- a cache object is replaced between verification and model use; +- a corrupted canonical-name object is extremely large and exhausts temporary storage before checksum rejection; +- a technically valid upstream checkpoint is shipped or advertised commercially despite the stated scientific-purpose restriction; - a third-party mirror or converted artifact is mistaken for a new commercial license grant. ### Mitigations -- exact allowlist for the currently supported `htdemucs` cached checkpoint filename; -- regular-file and no-symlink preflight; -- strict parsing of the registered eight-hex lowercase checksum suffix; -- bounded streaming SHA-256 verification against that Demucs checksum prefix before upstream resolution; -- bounded fail-closed error before entering Demucs when local evidence is absent or modified; +- exact allowlist for the currently supported `htdemucs` checkpoint name; +- regular-file, no-follow, and descriptor identity checks; +- positive-size and 128 MiB compatibility ceiling before snapshotting; +- streaming byte-count enforcement during the snapshot copy so post-`fstat` growth also fails closed; +- streaming SHA-256 verification against the canonical Demucs checksum prefix; +- private temporary local repository built from the verified descriptor bytes; +- explicit `repo=snapshot_root`, keeping upstream model resolution on `LocalRepo` instead of `RemoteRepo`; +- bounded failure before Demucs deserialization for missing, modified, oversized, or otherwise inadmissible cache state; - no heuristic-success fallback; -- dedicated regressions proving missing and checksum-mismatched checkpoints never invoke the upstream resolver; -- #1181 blocks commercial packaging/auto-download/rights claims for the upstream pretrained weights until explicit rights or an admissible replacement exists; -- release model packaging remains a separate Distribution prerequisite rather than an ad-hoc download in MIR code. +- #1181 blocks commercial packaging/auto-download/rights claims until explicit rights or an admissible replacement exists; +- #1180 retains ownership of immutable release artifact, full digest/signature, inventory, package, signing, and updater/rollback evidence. ### Remaining risk -The current repair is an immediate local-first compatibility guard, not the final release artifact boundary. The eight-hex suffix is only a truncated upstream checksum convention; it is not a repository-owned full SHA-256, signature or immutable release provenance statement. The existing supplemental component inventory also does not list a shipped htdemucs checkpoint. - -There is still a preflight-to-upstream-resolver TOCTOU window: BandScope closes its verification descriptor before `get_model` reopens the cache path. If the checkpoint disappears or is replaced after verification, the upstream remote path can become reachable or another object can be presented. A future technically admissible model therefore needs a local-only loader that consumes an already-open or immutable verified artifact without remote fallback or pathname re-open race. +The current path is still a compatibility bridge around a developer/runtime torch cache, not a commercial release artifact boundary. The eight-hex suffix is truncated upstream integrity evidence, not a repository-owned full SHA-256, signature, provenance receipt, or exact package manifest. The 128 MiB ceiling is deliberately a generic safety limit rather than the exact size of an admitted release artifact. -The upstream pretrained `htdemucs` weights are additionally blocked for commercial release by #1181 unless an explicit grant is obtained. Even a perfect local-only loader and full digest would not resolve that rights constraint. +Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. A commercially admitted release should minimize code-executing model formats where practical or bind any unavoidable format to immutable package/signature provenance and a narrow loader. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity check passes. ### Test points - absent local checkpoint: upstream resolver call count remains zero; -- checksum-mismatched cached checkpoint: upstream resolver call count remains zero; -- checksum-matching registered fixture: existing offline resolver remains usable; -- unsupported model name: fail closed without lookup; -- symlink/non-regular checkpoint: fail closed; -- commercial release: exact model rights evidence is present and linked to the immutable artifact, or the upstream weights are absent from release inputs; -- released admissible model artifact: full checksum/signature, inventory, package and offline Windows/macOS real-audio acceptance before release. +- checksum-mismatched cached checkpoint: resolver call count remains zero; +- checksum-matching fixture: resolver receives only the private snapshot repository; +- original cache pathname replaced after snapshot: private snapshot bytes remain unchanged; +- checkpoint larger than the active resource ceiling: resolver call count remains zero; +- unsupported model name and symlink/non-regular object: fail closed; +- commercial release: exact rights evidence exists for the immutable artifact or the upstream weights are absent from release inputs; +- released admissible model: exact full digest/signature, exact size, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. ## Effect -The normal missing-model path no longer begins an implicit model download, and a modified cache object with the expected filename no longer reaches Demucs model resolution solely by name. A machine without a locally admitted checkpoint receives a bounded separation-unavailable failure instead of silently becoming network-dependent or deserializing unchecked cached bytes. - -The next buyer-visible gap is no longer just "bundle htdemucs for offline use." The upstream pretrained weights are not commercially releasable on the currently documented basis. BandScope must obtain explicit rights or select/train a commercially admissible model, then bind that artifact to the local-only integrity/provenance boundary without regressing real-audio rehearsal quality. +Ordinary missing-model execution no longer begins an implicit model download. Modified or oversized cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from the exact descriptor BandScope verified rather than reopening the mutable torch-cache pathname. These controls establish a technical local-first compatibility boundary; they do not authorize commercial use of the upstream weights. ## Follow-up -1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact pretrained weights or select/train a commercially admissible replacement with traceable training-data/model rights. -2. For the admissible model, establish exact version, full digest/signature, storage/package location and release/update/rollback policy under Distribution. -3. Replace the preflight-plus-upstream-resolver compatibility path with a local-only loader bound to an already-open or immutable verified admissible artifact, eliminating remote-fallback/path-reopen behavior. -4. Add the released model to `supply-chain/supplemental-component-inventory.json` and SBOM/provenance/NOTICE evidence as applicable. -5. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics and explicit uncertainty/claim boundaries. -6. Keep #770 as the scientific-accuracy owner; model-delivery/licensing evidence must not substitute for MIR-quality evidence. +1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact upstream weights or select/train a commercially admissible replacement with traceable training-data/model rights. +2. Under #1180, establish the admitted model's exact version, exact byte size, full digest/signature, package location, supplemental inventory/SBOM/NOTICE, signing/notarization, and update/rollback policy. +3. Replace torch-cache compatibility discovery with a Distribution-owned immutable local artifact path/manifest. Retain the descriptor-bound/private-load principle and no-remote-fallback invariant. +4. Evaluate whether a lower-risk model serialization format can replace native checkpoint deserialization without sacrificing supported-platform behavior or scientific accuracy; document the decision and removal condition if not. +5. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics, and explicit uncertainty/claim boundaries under #770. ## References @@ -148,10 +155,6 @@ Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid transformers for music sou Défossez, A. (2022). Re: License of pre-trained models (Issue comment 1134828611). *facebookresearch/demucs* (Issue #327). https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611 -Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/pretrained.py - -Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/repo.py - -Meta Platforms, Inc. (2023). Demucs remote model manifest. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/main/demucs/remote/files.txt +Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/pretrained.py -PyTorch Contributors. (2026). `torch.hub`: model download and cache behavior. *pytorch/pytorch*. https://github.com/pytorch/pytorch/blob/main/torch/hub.py +Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/repo.py From 6cdea97712d95be4af17d2d367798fe3dbd148f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:24:12 +0900 Subject: [PATCH 408/448] docs(changelog): record bounded local model load --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61799975c..72634ce4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. - Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. - Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. +- Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, or over-128-MiB checkpoint state before model deserialization so mutable cache replacement and oversized local artifacts cannot alter or exhaust one analysis load. ## [0.1.3] - 2026-04-29 From f4ef3dc86e34432936b2febb152991af70e57bd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:44:02 +0900 Subject: [PATCH 409/448] test(mir): reject Demucs checkpoint growth after preflight --- .../tests/test_demucs_local_model_boundary.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 0369ea8d5..c8970a112 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -182,3 +182,53 @@ def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: AudioStemSeparator()._load_model() assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_checkpoint_growth_after_descriptor_preflight( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject bytes beyond the descriptor size admitted before snapshot copy.""" + checkpoint_bytes = b"checkpoint-grew-after-preflight" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + checkpoint_path = checkpoint_root / checkpoint_name + checkpoint_path.write_bytes(checkpoint_bytes) + checkpoint_stat = checkpoint_path.stat() + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("post-preflight growth must not reach Demucs deserialization") + + real_fstat = audio_separator_module.os.fstat + + def stale_preflight_size(descriptor: int) -> object: + current = real_fstat(descriptor) + if current.st_dev == checkpoint_stat.st_dev and current.st_ino == checkpoint_stat.st_ino: + return SimpleNamespace( + st_mode=current.st_mode, + st_dev=current.st_dev, + st_ino=current.st_ino, + st_size=current.st_size - 1, + ) + return current + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setattr(audio_separator_module.os, "fstat", stale_preflight_size) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + AudioStemSeparator()._load_model() + + assert calls["count"] == 0 From 0d0c6c3263e9b72b5aec554c1824de3d004b5831 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:45:51 +0900 Subject: [PATCH 410/448] fix(mir): bind Demucs snapshot to admitted descriptor size --- .../separation/audio_separator.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 4bb09e268..f53f0a7a3 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -140,10 +140,11 @@ def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> s The mutable torch cache pathname is used only to acquire the source descriptor. The descriptor must represent the same regular file observed by ``lstat``; ``O_NOFOLLOW`` is requested where the host exposes it. The admitted object is - size-bounded before and during the copy so cache corruption cannot consume - unbounded snapshot storage. The copied bytes must reproduce the checksum - prefix encoded in the canonical checkpoint filename. Demucs later - deserializes only the private snapshot. + size-bounded before the copy, and the snapshot must reproduce exactly the + descriptor size observed at admission. Short reads or later growth fail + closed before resolver/deserialization. The copied bytes must also reproduce + the checksum prefix encoded in the canonical checkpoint filename. Demucs + later deserializes only the private snapshot. """ checkpoint_name = _DEMUCS_LOCAL_CHECKPOINTS.get(model_name) identity = ( @@ -178,15 +179,18 @@ def _snapshot_local_demucs_checkpoint(model_name: str, snapshot_root: Path) -> s return None digest = hashlib.sha256() - copied_bytes = 0 with os.fdopen(descriptor, "rb", closefd=False) as checkpoint_file: with snapshot_path.open("xb") as snapshot_file: - while chunk := checkpoint_file.read(_COPY_CHUNK_BYTES): - copied_bytes += len(chunk) - if copied_bytes > _MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES: - raise ValueError("local Demucs checkpoint exceeds resource limit") + remaining = descriptor_stat.st_size + while remaining: + chunk = checkpoint_file.read(min(_COPY_CHUNK_BYTES, remaining)) + if not chunk: + raise ValueError("local Demucs checkpoint changed during snapshot") digest.update(chunk) snapshot_file.write(chunk) + remaining -= len(chunk) + if checkpoint_file.read(1): + raise ValueError("local Demucs checkpoint changed during snapshot") snapshot_file.flush() os.fsync(snapshot_file.fileno()) finally: @@ -478,4 +482,4 @@ def _as_float_array(values: object) -> AudioStemArray: raise ValueError(_MODEL_OUTPUT_ERROR) from error if array.size == 0 or not np.isfinite(array).all(): raise ValueError(_MODEL_OUTPUT_ERROR) - return cast(AudioStemArray, array) + return cast(AudioStemArray, array) \ No newline at end of file From f9758eb629342e7c5390ad18861b8123e67cc77e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:47:00 +0900 Subject: [PATCH 411/448] docs(traceability): bind Demucs snapshot to descriptor size --- .../demucs-local-model-admission.md | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index 4c4f90d38..3aeb064a2 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -10,7 +10,7 @@ For the Demucs 4.x API currently consumed by BandScope, `get_model(..., repo=Non The next implementation still verified the mutable torch-cache object and then let the upstream resolver reopen that pathname. A replacement or deletion after verification could therefore invalidate the evidence. The current implementation instead copies bytes from the verified descriptor into a private local Demucs repository and calls `get_model(signature, repo=snapshot_root)`. Demucs consequently resolves through `LocalRepo`; a later mutation of the torch-cache pathname cannot change the model bytes being deserialized or reactivate `RemoteRepo` for that load. -That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. The current boundary rejects empty or over-limit descriptors before copying and enforces the same ceiling while streaming, covering growth after `fstat` as well. +That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. A 128 MiB ceiling repaired the unbounded-copy case, but the copy still streamed until EOF rather than binding materialization to the descriptor size observed at `fstat`. If the file grew after preflight while remaining below the ceiling, extra bytes could still enter the private snapshot before checksum rejection. The current boundary therefore snapshots exactly the descriptor-reported byte count, rejects short reads, and rejects any byte beyond that admitted count before resolver/deserialization. A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, or conversion of the same weights cannot create commercial rights. BandScope issue #1181 owns that release blocker. @@ -22,7 +22,7 @@ A commercial review exposed an independent rights blocker. The upstream Demucs i - The private compatibility snapshot is temporary runtime authority, not a released model artifact or provenance statement. - The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. - Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, exact full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than MIR inference code. -- A missing, modified, oversized, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- A missing, modified, oversized, size-racing, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. - Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. ## RED evidence @@ -35,6 +35,8 @@ Commit `9fd9b562d068dea1e9348584f53ced6d9c6c0553` adds the immutable-snapshot re Commit `7ac4bc1d35ff736966ed556407b6ff56d03942c0` adds the resource-bound RED. A checksum-valid fixture is deliberately larger than a monkeypatched local-model ceiling; the Demucs resolver is forbidden. The predecessor had no checkpoint-size admission rule, so it would continue to resolution rather than fail before model loading. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for this intermediate head. +Commit `f4ef3dc86e34432936b2febb152991af70e57bd1` adds the descriptor-size continuity RED. The fixture presents a stable regular checkpoint whose descriptor preflight reports one byte less than the bytes subsequently readable from that same descriptor and forbids any Demucs resolver call. The predecessor streamed until EOF, so the extra post-preflight byte entered the private snapshot and a checksum-valid full byte sequence could still reach model resolution. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for the intermediate head. + ## Selected repair Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` established the first narrow admission guard: the production `htdemucs` checkpoint must already exist locally, be a regular non-symlink object, and unsupported/missing inputs fail with the bounded message `Stem separation model weights are not installed locally.` @@ -43,7 +45,9 @@ Commit `d0432187eea6ec94a247d78f1c02f69e7185a5a1` parses the canonical lowercase Commit `3662de13e1ffae2ac2337835dd6f317011e81bff` closes the mutable-cache pathname gap. BandScope opens the canonical cache object with no-follow semantics where available, verifies that the opened descriptor is the same regular object observed by `lstat`, copies and hashes that descriptor into a process-private temporary Demucs repository, and invokes `get_model(signature, repo=snapshot_root)`. Upstream `get_model` therefore uses `LocalRepo`; the mutable torch-cache pathname is no longer reopened by the model resolver and `RemoteRepo` is not selected for this load. -Commit `c21c6c4476f7c9ae937a24dda77eb841515ed315` bounds that compatibility snapshot to 128 MiB. The descriptor must report a positive size no greater than the ceiling before copying. The copy loop also counts actual bytes and fails before writing an over-limit chunk, so growth after the metadata check cannot cause unbounded snapshot storage. A checksum mismatch, size violation, file-identity mismatch, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. +Commit `c21c6c4476f7c9ae937a24dda77eb841515ed315` bounds that compatibility snapshot to 128 MiB. The descriptor must report a positive size no greater than the ceiling before copying, so an already-oversized cache object cannot consume unbounded snapshot storage. + +Commit `0d0c6c3263e9b72b5aec554c1824de3d004b5831` then binds snapshot materialization to that admitted descriptor size. The copy reads exactly `descriptor_stat.st_size` bytes, fails on an early EOF, and probes one additional byte without copying it; any post-`fstat` growth therefore fails before Demucs resolution instead of entering the snapshot. SHA-256 verification of those exact bytes against the canonical filename prefix remains required. A checksum mismatch, size violation, file-identity mismatch, size race, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must eventually replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, package placement, and update/rollback compatibility are release inputs. @@ -63,9 +67,9 @@ Rejected. Upstream `LocalRepo` interprets the checksum-bearing filename as integ Rejected. It leaves a verification-to-use pathname race. Copying from the verified descriptor into a private repository binds the bytes used by the resolver to the bytes BandScope admitted. -### Copy any sized regular checkpoint and reject only after hashing +### Copy until EOF under only a generic maximum -Rejected. Integrity failure after an unbounded copy is still a resource-exhaustion path. Model artifacts require an explicit byte ceiling before and during materialization. +Rejected. A generic maximum prevents unbounded storage but does not preserve the exact descriptor-size observation that authorized the snapshot. A file that grows after `fstat` but remains below the ceiling would contribute unadmitted bytes before checksum rejection. Exact-count copy plus an extra-byte probe keeps resource and identity evidence aligned. ### Download or bundle the checkpoint from this MIR/Project Persistence lane @@ -87,11 +91,11 @@ Rejected. The retired heuristic is not a scientifically acceptable substitute fo ### Attack surface -The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, temporary snapshots, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. +The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. ### Trust boundary -Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. +Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. ### Realistic threats @@ -100,6 +104,7 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - a symlink/non-regular object is presented under the expected cache pathname; - modified bytes retain a trusted-looking checkpoint filename; - a cache object is replaced between verification and model use; +- a cache descriptor grows or shrinks after size preflight and changes the bytes copied into the private repository; - a corrupted canonical-name object is extremely large and exhausts temporary storage before checksum rejection; - a technically valid upstream checkpoint is shipped or advertised commercially despite the stated scientific-purpose restriction; - a third-party mirror or converted artifact is mistaken for a new commercial license grant. @@ -109,11 +114,11 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - exact allowlist for the currently supported `htdemucs` checkpoint name; - regular-file, no-follow, and descriptor identity checks; - positive-size and 128 MiB compatibility ceiling before snapshotting; -- streaming byte-count enforcement during the snapshot copy so post-`fstat` growth also fails closed; +- exact descriptor-size snapshotting with early-EOF and extra-byte rejection, so post-`fstat` shrink/growth fails closed; - streaming SHA-256 verification against the canonical Demucs checksum prefix; - private temporary local repository built from the verified descriptor bytes; - explicit `repo=snapshot_root`, keeping upstream model resolution on `LocalRepo` instead of `RemoteRepo`; -- bounded failure before Demucs deserialization for missing, modified, oversized, or otherwise inadmissible cache state; +- bounded failure before Demucs deserialization for missing, modified, oversized, size-racing, or otherwise inadmissible cache state; - no heuristic-success fallback; - #1181 blocks commercial packaging/auto-download/rights claims until explicit rights or an admissible replacement exists; - #1180 retains ownership of immutable release artifact, full digest/signature, inventory, package, signing, and updater/rollback evidence. @@ -131,13 +136,14 @@ Demucs/torch deserialization still consumes a trusted technical snapshot in its - checksum-matching fixture: resolver receives only the private snapshot repository; - original cache pathname replaced after snapshot: private snapshot bytes remain unchanged; - checkpoint larger than the active resource ceiling: resolver call count remains zero; +- descriptor preflight smaller than readable bytes: extra bytes do not enter the snapshot and resolver call count remains zero; - unsupported model name and symlink/non-regular object: fail closed; - commercial release: exact rights evidence exists for the immutable artifact or the upstream weights are absent from release inputs; - released admissible model: exact full digest/signature, exact size, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. ## Effect -Ordinary missing-model execution no longer begins an implicit model download. Modified or oversized cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from the exact descriptor BandScope verified rather than reopening the mutable torch-cache pathname. These controls establish a technical local-first compatibility boundary; they do not authorize commercial use of the upstream weights. +Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. These controls establish a technical local-first compatibility boundary; they do not authorize commercial use of the upstream weights. ## Follow-up From c7d22c084d145c153e2690b32ff7ab2073594107 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:47:43 +0900 Subject: [PATCH 412/448] docs(changelog): record exact Demucs snapshot size binding --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72634ce4b..9aadba86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ - Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. - Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. - Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. -- Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, or over-128-MiB checkpoint state before model deserialization so mutable cache replacement and oversized local artifacts cannot alter or exhaust one analysis load. +- Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load. ## [0.1.3] - 2026-04-29 @@ -93,4 +93,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From bd18d2825ce9abc3f76879aa6be28711f215be74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:06:05 +0900 Subject: [PATCH 413/448] test(audio): use one separator import boundary --- .../tests/test_audio_admitted_snapshot.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/tests/test_audio_admitted_snapshot.py b/services/analysis-engine/tests/test_audio_admitted_snapshot.py index 88dfbf0cb..50c5a23d3 100644 --- a/services/analysis-engine/tests/test_audio_admitted_snapshot.py +++ b/services/analysis-engine/tests/test_audio_admitted_snapshot.py @@ -8,7 +8,6 @@ import pytest import bandscope_analysis.separation.audio_separator as audio_separator_module -from bandscope_analysis.separation.audio_separator import AudioSeparationConfig, AudioStemSeparator def _same_size_bytes(seed: bytes, marker: int) -> bytes: @@ -18,10 +17,13 @@ def _same_size_bytes(seed: bytes, marker: int) -> bytes: return bytes(payload) -def _separator() -> AudioStemSeparator: +def _separator() -> audio_separator_module.AudioStemSeparator: """Build the bounded separator used by the byte-continuity regressions.""" - return AudioStemSeparator( - AudioSeparationConfig(target_sample_rate=8_000, max_file_bytes=1_000_000) + return audio_separator_module.AudioStemSeparator( + audio_separator_module.AudioSeparationConfig( + target_sample_rate=8_000, + max_file_bytes=1_000_000, + ) ) @@ -71,7 +73,7 @@ def fake_decode(source, *, policy): monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) monkeypatch.setattr( - AudioStemSeparator, + audio_separator_module.AudioStemSeparator, "_separate_signal", lambda _self, audio, _sample_rate: { "vocals": np.zeros(audio.size, dtype=np.float32), @@ -109,7 +111,7 @@ def fake_decode(*_args, **_kwargs): monkeypatch.setenv("BANDSCOPE_ADMITTED_AUDIO_SHA256", hashlib.sha256(original).hexdigest()) monkeypatch.setattr(audio_separator_module, "decode_mono_audio", fake_decode) monkeypatch.setattr( - AudioStemSeparator, + audio_separator_module.AudioStemSeparator, "_separate_signal", lambda _self, audio, _sample_rate: { "vocals": np.zeros(audio.size, dtype=np.float32), From e8aa3db53b6102fdd93a6f6988bd70d908565daf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:06:36 +0900 Subject: [PATCH 414/448] test(separation): use one Demucs separator import boundary --- .../tests/test_demucs_local_model_boundary.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index c8970a112..13ffacdf7 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -10,7 +10,6 @@ import pytest import bandscope_analysis.separation.audio_separator as audio_separator_module -from bandscope_analysis.separation.audio_separator import AudioStemSeparator class _FakeModel: @@ -61,7 +60,7 @@ def forbidden_remote_lookup(_name: str, **_kwargs: object) -> _FakeModel: ) with pytest.raises(ValueError, match="model weights are not installed locally"): - AudioStemSeparator()._load_model() + audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 @@ -102,7 +101,7 @@ def fake_local_lookup(name: str, *, repo: Path | None = None) -> _FakeModel: get_model=fake_local_lookup, ) - model = AudioStemSeparator()._load_model() + model = audio_separator_module.AudioStemSeparator()._load_model() assert isinstance(model, _FakeModel) assert len(calls) == 1 @@ -139,7 +138,7 @@ def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: ) with pytest.raises(ValueError, match="model weights are not installed locally"): - AudioStemSeparator()._load_model() + audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 @@ -179,7 +178,7 @@ def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: ) with pytest.raises(ValueError, match="model weights are not installed locally"): - AudioStemSeparator()._load_model() + audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 @@ -229,6 +228,6 @@ def stale_preflight_size(descriptor: int) -> object: ) with pytest.raises(ValueError, match="model weights are not installed locally"): - AudioStemSeparator()._load_model() + audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 From 9ceeb2faa73317e591a1741a0d246b82f9311423 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 02:08:15 +0900 Subject: [PATCH 415/448] test(project): preserve reopen source authority on resave --- ...App.project-save-source-authority.test.tsx | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/App.project-save-source-authority.test.tsx b/apps/desktop/src/App.project-save-source-authority.test.tsx index 5e6de82c8..c52aada7b 100644 --- a/apps/desktop/src/App.project-save-source-authority.test.tsx +++ b/apps/desktop/src/App.project-save-source-authority.test.tsx @@ -1,9 +1,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { vi, describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; -const { mockSaveProject } = vi.hoisted(() => ({ +const { mockLoadProject, mockLoadProjectDocument, mockSaveProject } = vi.hoisted(() => ({ + mockLoadProject: vi.fn(), + mockLoadProjectDocument: vi.fn(), mockSaveProject: vi.fn().mockResolvedValue(undefined) })); @@ -51,11 +53,19 @@ vi.mock("./lib/analysis", async (importActual) => { result: createDemoRehearsalSong() }), subscribeToAnalysisJobUpdates: async () => () => undefined, + loadProject: (...args: unknown[]) => mockLoadProject(...args), + loadProjectDocument: (...args: unknown[]) => mockLoadProjectDocument(...args), saveProject: (...args: unknown[]) => mockSaveProject(...args) }; }); describe("App local-audio save authority", () => { + beforeEach(() => { + mockLoadProject.mockReset(); + mockLoadProjectDocument.mockReset(); + mockSaveProject.mockClear(); + }); + it("saves the analyzed local project with its exact native project id", async () => { render(); @@ -75,4 +85,36 @@ describe("App local-audio save authority", () => { ); }); }); -}); + + it("preserves reopened source identity and playback-source intent on resave", async () => { + const song = createDemoRehearsalSong(); + const projectDocument = { + song, + preferences: { selectedPlaybackSource: "vocals" as const }, + sourceReference: { + projectId: "project-500-5", + artifactName: "source.wav", + extension: "wav" as const, + fileSizeBytes: 8192, + contentSha256: "a".repeat(64) + } + }; + mockLoadProject.mockResolvedValueOnce(song); + mockLoadProjectDocument.mockResolvedValueOnce(projectDocument); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "vocals", + "project-500-5" + ); + }); + }); +}); \ No newline at end of file From 9a9151d1a5420c83218ac220d29cb144c9e3b45d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 02:11:44 +0900 Subject: [PATCH 416/448] fix(project): retain reopen persistence identity --- apps/desktop/src/App.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index fc47f68fa..69111efb5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,12 +36,13 @@ import { getAnalysisJobStatus, importYoutubeUrl, isSupportedYoutubeUrl, - loadProject, + loadProjectDocument, MAX_YOUTUBE_URL_LENGTH, saveProject, subscribeToAnalysisJobUpdates, selectLocalAudioSource, - startAnalysisJob + startAnalysisJob, + type SelectedPlaybackSource } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; @@ -255,6 +256,7 @@ export function App() { const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); const [jobResultPublicationProjectId, setJobResultPublicationProjectId] = useState(null); + const [jobResultSelectedPlaybackSource, setJobResultSelectedPlaybackSource] = useState("full_mix"); const [jobError, setJobError] = useState(null); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); @@ -291,6 +293,7 @@ export function App() { setJobResult(nextStatus.result); setJobResultBootstrap(activeAnalysisBootstrap); setJobResultPublicationProjectId(activeAnalysisPublicationProjectId); + setJobResultSelectedPlaybackSource("full_mix"); setActiveAnalysisBootstrap(null); setActiveAnalysisPublicationProjectId(null); setJobError(null); @@ -410,6 +413,7 @@ export function App() { setJobResult(nextStatus.result); setJobResultBootstrap(submittedBootstrap); setJobResultPublicationProjectId(submittedPublicationProjectId); + setJobResultSelectedPlaybackSource("full_mix"); setActiveAnalysisBootstrap(null); setActiveAnalysisPublicationProjectId(null); } else { @@ -488,10 +492,11 @@ export function App() { /** Documented. */ const handleLoadProject = async () => { try { - const song = await loadProject(); - setJobResult(song); + const projectDocument = await loadProjectDocument(); + setJobResult(projectDocument.song); setJobResultBootstrap(null); - setJobResultPublicationProjectId(null); + setJobResultPublicationProjectId(projectDocument.sourceReference?.projectId ?? null); + setJobResultSelectedPlaybackSource(projectDocument.preferences.selectedPlaybackSource); setJobError(null); setSelectedBootstrap(null); setSelectedPublicationProjectId(null); @@ -508,11 +513,11 @@ export function App() { /** Documented. */ const handleSaveProject = async () => { try { - if (jobResultPublicationProjectId) { - await saveProject(jobResult!, "full_mix", jobResultPublicationProjectId); - } else { - await saveProject(jobResult!); - } + await saveProject( + jobResult!, + jobResultSelectedPlaybackSource, + jobResultPublicationProjectId ?? undefined + ); } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); From d76d90bc3cd5b097829048fc95e716fd279677c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 02:13:06 +0900 Subject: [PATCH 417/448] docs(project): trace reopen save authority --- .../project-v3-source-restart-readmission.md | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/traceability/project-v3-source-restart-readmission.md b/docs/traceability/project-v3-source-restart-readmission.md index 0f8b589a4..b17d77c6b 100644 --- a/docs/traceability/project-v3-source-restart-readmission.md +++ b/docs/traceability/project-v3-source-restart-readmission.md @@ -6,6 +6,8 @@ Project format v3 can persist a path-free `sourceReference` after Resource Admis The Save path keeps the original user path out of durable project truth and stores `projectId`, fixed `artifactName`, admitted `extension`, bounded `fileSizeBytes`, and canonical lowercase `contentSha256`. Restart therefore needs three distinct steps: validate durable evidence before any filesystem lookup, resolve only an already-existing app-local project aggregate without provisioning a replacement directory, then re-establish native content identity only from an app-owned descriptor whose bytes reproduce the exact persisted receipt. +A later mounted round-trip review found a separate persistence loss after native re-admission had already succeeded. The renderer compatibility `loadProject()` returned only `document.song`; `App` then cleared the restored project selector and later called `saveProject(song)` with the default `full_mix` preference and no native project id. An ordinary Open Project → Save Project sequence could therefore publish a new `.bscope` document without the verified `sourceReference` and could silently replace a persisted stem preference such as `vocals` with `full_mix`. + ## Constraints - Resource Admission owns audio byte admission and `LocalAudioPublicationIdentity`; Project Persistence owns the durable v3 document; Active Player owns fresh playback authority. @@ -16,6 +18,8 @@ The Save path keeps the original user path out of durable project truth and stor - Size is a bounded preflight, not content identity. SHA-256 equality is required for the opened bytes. - The verifier must stop after the expected byte length plus a one-byte growth probe rather than hashing an unexpectedly large object. - Historical projects without `sourceReference` remain without source authority; migration does not invent evidence or provision a project root. +- Renderer save IPC may select only the already-restored BandScope project id. It must not author a path, digest, artifact name, byte count, or `sourceReference`. +- The mounted renderer must preserve the complete reopened Project Persistence intent needed for a subsequent save. A song-only compatibility view cannot be treated as the durable aggregate. - A content-identity match alone does not prove descriptor-bound parent-directory containment, future path stability, audio decodability, or current playable-stem availability. Those remain explicit runtime responsibilities before playback authority is issued. ## RED evidence @@ -30,6 +34,8 @@ The later native-opener RED `66ed5ec328d498bae59af2814b20a16884f30bae` required `1c8bc3d0668d505dbd94ebe23d58584270d6b09b` adds the app-local-base authority regression. It constructs a valid project directory below a real app-local fixture, exposes that fixture only through a symlinked base path, and requires reopen root resolution to reject the linked base instead of treating the ordinary child directory reached through it as app-owned authority. The predecessor checked only the final project child and therefore admitted that redirection. +`9ceeb2faa73317e591a1741a0d246b82f9311423` adds the mounted Open→Save regression. It supplies a reopened v3 document carrying `preferences.selectedPlaybackSource = vocals` and `sourceReference.projectId = project-500-5`, then requires the Save action to call the persistence bridge with that same preference and exact native project selector. The predecessor `App` called the song-only `loadProject()` compatibility wrapper, cleared `jobResultPublicationProjectId`, and later saved with the `full_mix` default, so the new contract fails on that predecessor without relying on a mock-only success path. + The deterministic PCM/WAV-like bytes used by the core and native filesystem contracts are unit fixtures only. They validate bounded content identity and filesystem authority composition, not MIR or decoder quality. They are not production scientific acceptance; rights-cleared real decoded audio remains required for release acceptance. ## Selected design @@ -46,6 +52,8 @@ Production integration `0f20b072a245feca59c72ac29b21968b41982f46` wires this seq The restored bootstrap keeps `source_path` transient in native memory. The durable document still contains no filesystem path, and renderer save IPC remains unable to author a digest, artifact name, byte count, or `sourceReference`. +`9a9151d1a5420c83218ac220d29cb144c9e3b45d` repairs the mounted renderer round trip without weakening that boundary. `App` now consumes the complete `loadProjectDocument()` result, keeps only the durable `selectedPlaybackSource` intent plus the path-free `sourceReference.projectId` selector needed for later native lookup, and passes them back to `saveProject`. The renderer still never reconstructs or submits the digest, artifact name, byte count, or path. New analysis results initialize the persistence preference to `full_mix`; reopened projects preserve the preference that was actually stored. + ## Rejected alternatives **Trust the persisted digest after schema validation.** Rejected because a syntactically valid digest only states what bytes are expected; it does not prove the current app-owned artifact still contains those bytes. @@ -62,6 +70,10 @@ The restored bootstrap keeps `source_path` transient in native memory. The durab **Hash until EOF without the persisted bound.** Rejected because a corrupted or replaced object could force unnecessary I/O before mismatch is known. The existing verifier reads the expected bytes and one growth probe. +**Keep using the song-only `loadProject()` wrapper and infer save authority later.** Rejected because song data does not contain the native project selector or the versioned playback-source preference. A global “last opened project” shortcut would become ambiguous as soon as more than one aggregate has native state and would collapse Project Persistence authority into renderer session history. + +**Persist renderer-authored `sourceReference` during resave.** Rejected because it would let the WebView author filesystem/content identity. The mounted layer carries only the already-validated project id; native retained identity remains the source-reference authority. + **Issue playback authority immediately after hash equality.** Rejected because content identity does not establish descriptor-bound parent location authority, future path stability, decoder acceptance, or current playable-stem availability. ## Security Notes @@ -70,17 +82,19 @@ The restored bootstrap keeps `source_path` transient in native memory. The durab The `.bscope` document and renderer-visible data are untrusted. `sourceReference` crosses Project Persistence as passive evidence. The reverse ACL validates every durable identity field before any filesystem opener is called. Tauri derives the app-local project base from its native path API; the read-side resolver requires that base and the exact project child to pre-exist without direct link/reparse indirection, and the core ACL requires that child to remain bound to the same BandScope project id. +The mounted renderer receives the validated document but does not become the source-reference authority. For a later save it retains only the opaque project id selector and the versioned playback-source preference. Native state resolves that selector back to the verified `LocalAudioPublicationIdentity` and injects the path-free source reference immediately before serialization. + ### Allowlist and validation The Resource Admission identity builder validates the BandScope project-id grammar, admitted extension allowlist, fixed `source.` artifact name, positive bounded size, and canonical lowercase 64-hex SHA-256 representation. The project-root adapter reuses those canonical rules and additionally rejects a root whose final component does not equal the validated project id. The Tauri read-side resolver refuses a missing, linked, or reparse app-local base/project directory rather than provisioning it. ### Mitigations -Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate below a directly non-linked app-local base; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. +Project Persistence supplies path-free durable evidence; the read-side resolver selects only an already-existing project aggregate below a directly non-linked app-local base; the project-root ACL validates the evidence and derives one fixed source path; the injected native opener establishes supported-platform final-component no-follow/reparse and file-identity authority; Resource Admission verifies the opened bytes against the persisted bounded receipt. Native publication and bootstrap state are restored only after all those steps succeed. A reopened mounted save reuses only the verified native project selector and preserves the stored playback-source intent; it does not copy source evidence out of the document and send it back as renderer-authored authority. ### Safe failure -Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or directly linked/reparse app-local base or project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. +Malformed durable evidence, forged artifact names, cross-project root substitution, a missing or directly linked/reparse app-local base or project root, opener failure, size changes, growth, truncation, and SHA-256 mismatch all return the bounded project-workspace diagnosis. No failed re-admission restores native publication/bootstrap state or playback capability. A source-bearing document that cannot restore its native identity does not reach the mounted renderer and therefore cannot later be resaved as if its source authority were still valid. ### Logging and privacy @@ -88,17 +102,19 @@ The reverse ACL never receives the original user-selected path. SHA-256 remains ### Test points -`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/project_root_existing_authority.rs` adds the direct app-local-base redirection regression. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. +`apps/desktop/core/tests/local_audio_restart_readmission.rs` covers exact-byte success, same-size mutation, growth, truncation, forged artifact identity, malformed durable identity, bounded read failure, exact fixed-path derivation, and cross-project-root rejection. `apps/desktop/src-tauri/tests/project_persistence_open_authority.rs` composes the root ACL with the canonical native opener and proves the read-side project-root resolver accepts an existing regular aggregate, refuses a missing aggregate without creating it, and rejects Unix directory symlinks. `apps/desktop/src-tauri/tests/project_root_existing_authority.rs` adds the direct app-local-base redirection regression. `apps/desktop/src-tauri/tests/local_audio_publication_contract.rs` requires production `load_project` to restore source authority before returning the document and forbids the provisioning `app_owned_root(..., "projects", ...)` path inside that command. `apps/desktop/src/App.project-save-source-authority.test.tsx` covers both newly analyzed local-audio save authority and the mounted reopen→resave contract that preserves the exact native project selector plus non-default playback-source intent. Existing Resource Admission tests remain canonical for bounded copy/publication receipts, known-answer SHA-256 vectors, maximum-size enforcement, and staging/publication failure separation. ### Realistic threats -Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, direct link/reparse redirection of the app-local base or project root, and final-component link/reparse redirection. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. +Relevant threats are local project corruption after reported Save, same-size replacement of `source.`, truncation or append caused by interrupted or external writes, tampered `.bscope` identity fields, attempts to smuggle traversal-like artifact names, substitution or deletion of the persisted project root, direct link/reparse redirection of the app-local base or project root, final-component link/reparse redirection, and semantic evidence loss during an otherwise successful Open→Save round trip. Hash equality is not treated as protection against a privileged attacker who can modify both the project document and app-owned artifact; that stronger local-compromise model requires separate platform storage and integrity controls. ### Remaining risk -Production `load_project` now restores verified full-mix publication identity and native bootstrap state, but it does not yet establish release-grade end-to-end playback authority. The verified file descriptor is consumed by SHA-256 verification and a transient path is retained for the later analysis process. A local mutation or replacement after verification but before the analysis/decoder opens that path is therefore a remaining time-of-check/time-of-use gap; a descriptor/capability-bound handoff or an equivalent immutable snapshot design is required before claiming strict byte continuity into decode/playback. +Production `load_project` restores verified full-mix publication identity and native bootstrap state. Before analysis queue admission, the retained identity is revalidated again; the analysis child copies that admitted source into a private snapshot, verifies exact byte count and SHA-256, and decodes the same snapshot. The earlier admitted-audio descriptor→decoder pathname-replacement gap is therefore closed for the analysis path. + +The remaining mounted buyer gap is audible playback authority, not persistence content identity. A reopened `selectedPlaybackSource` is still durable intent only. #1160 must compose it with freshly admitted Full mix/current-stem media authority, fail closed to Full mix when a preferred stem is unavailable, and prove stale/replaced media cannot retain audible authority. The current #970 branch also does not expose a reconstructed transient bootstrap object back through the ProjectDocument IPC contract, so mounted consumers that require transient project/cache/temp paths must obtain fresh native capability through their owning adapter rather than persisting those paths. -Descriptor-bound parent-directory authority also remains a known gap: direct app-local-base/project-root checks and final-component O_NOFOLLOW/reparse protection do not prevent concurrent replacement of those directories or redirection through an ancestor above the checked base. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, actual decoder re-admission, Active Player source reconciliation, preferred-stem-to-Full-mix fallback, and rights-cleared Windows/macOS real-audio acceptance remain required evidence. +Descriptor-bound parent-directory authority remains a known gap: direct app-local-base/project-root checks and final-component O_NOFOLLOW/reparse protection do not prevent concurrent replacement of those directories or redirection through an ancestor above the checked base. A directory-handle-relative design or equivalent supported-platform primitive is required for that stronger guarantee. Restart fault injection, mounted Active Player source reconciliation, rights-cleared Windows/macOS real-audio acceptance, and broader localization/accessibility evidence remain required before release readiness. ## Standards traceability From 767b87e3e2fec3116ec274c22db6995cbb2defc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 02:13:44 +0900 Subject: [PATCH 418/448] docs(changelog): record reopen resave authority --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aadba86e..930264d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed +- Preserve a reopened v3 project's native source selector and stored playback-source preference across an Open Project → Save Project round trip, so resaving cannot silently drop `sourceReference` or reset a non-default stem intent to Full mix. - Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work. - Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed. - Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget. From 5789562e716d955c758a7eb728140c5fcb02f779 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:14:04 +0900 Subject: [PATCH 419/448] test(mir): expose PyTorch weights-only model incompatibility --- .../tests/test_demucs_local_model_boundary.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 13ffacdf7..725513fd6 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import pickle import sys from pathlib import Path from types import ModuleType, SimpleNamespace @@ -231,3 +232,37 @@ def stale_preflight_size(descriptor: int) -> object: audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 + + +def test_demucs_model_load_bounds_pytorch_weights_only_incompatibility( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep PyTorch 2.6+ weights-only failures inside the local-model boundary.""" + checkpoint_bytes = b"legacy-demucs-package-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + + def incompatible_weights_only_load(_name: str, **_kwargs: object) -> _FakeModel: + raise pickle.UnpicklingError( + "Weights only load failed: unsupported GLOBAL demucs.htdemucs.HTDemucs" + ) + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=incompatible_weights_only_load, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally") as failure: + audio_separator_module.AudioStemSeparator()._load_model() + + assert "HTDemucs" not in str(failure.value) From d395c6055bb16cfc4a76f490f16e9e6540590fae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:15:45 +0900 Subject: [PATCH 420/448] fix(mir): bound PyTorch weights-only model load failure --- .../separation/audio_separator.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index f53f0a7a3..84849cf87 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -28,6 +28,8 @@ signature there, so upstream deserialization cannot reopen or download from the mutable cache pathname. Release bundling, full digest/signature provenance, and model-rights evidence remain Distribution work. +- PyTorch/Demucs checkpoint incompatibility fails with the same bounded local-model + diagnostic rather than exposing serialized class names or internal loader details. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -39,6 +41,7 @@ import hashlib import logging import os +import pickle import stat import sys import tempfile @@ -324,6 +327,8 @@ def _load_model(self) -> Any: checkpoint is copied from its verified descriptor into a private local repository. Passing that repository explicitly keeps Demucs on LocalRepo and prevents RemoteRepo/network fallback or a second open of the cache path. + PyTorch 2.6+ weights-only incompatibility is treated as an unavailable + admitted model; BandScope does not force unsafe legacy pickle loading here. """ if self._model is None: try: @@ -344,8 +349,11 @@ def _load_model(self) -> Any: if model_signature is None: raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) - with contextlib.redirect_stdout(sys.stderr): - model = get_model(model_signature, repo=snapshot_root) + try: + with contextlib.redirect_stdout(sys.stderr): + model = get_model(model_signature, repo=snapshot_root) + except pickle.UnpicklingError as error: + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) from error model.eval() self._model = model return self._model @@ -482,4 +490,4 @@ def _as_float_array(values: object) -> AudioStemArray: raise ValueError(_MODEL_OUTPUT_ERROR) from error if array.size == 0 or not np.isfinite(array).all(): raise ValueError(_MODEL_OUTPUT_ERROR) - return cast(AudioStemArray, array) \ No newline at end of file + return cast(AudioStemArray, array) From 4838ee8e066c2ca4cc44fda6c32150f0b29e3b23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:19:11 +0900 Subject: [PATCH 421/448] docs(trace): record PyTorch weights-only model boundary --- .../demucs-local-model-admission.md | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index 3aeb064a2..1c2f63a83 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -12,7 +12,9 @@ The next implementation still verified the mutable torch-cache object and then l That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. A 128 MiB ceiling repaired the unbounded-copy case, but the copy still streamed until EOF rather than binding materialization to the descriptor size observed at `fstat`. If the file grew after preflight while remaining below the ceiling, extra bytes could still enter the private snapshot before checksum rejection. The current boundary therefore snapshots exactly the descriptor-reported byte count, rejects short reads, and rejects any byte beyond that admitted count before resolver/deserialization. -A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, or conversion of the same weights cannot create commercial rights. BandScope issue #1181 owns that release blocker. +The live analysis lock now resolves `torch==2.12.1`. PyTorch changed `torch.load` so releases starting with 2.6 use `weights_only=True` by default when a custom `pickle_module` is not supplied. Native Demucs packages contain more than a plain tensor `state_dict`: upstream loading consumes serialized class/constructor metadata. A compatibility package may therefore raise `pickle.UnpicklingError` when the weights-only unpickler rejects a serialized global. That failure is security-relevant as well as operational: BandScope must not surface internal serialized class names to a buyer, silently switch to `weights_only=False`, or turn a compatibility failure into remote/model fallback. The current Signal/MIR boundary converts this incompatibility to the existing bounded local-model-unavailable diagnostic while leaving the release serialization decision with Distribution. + +A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, conversion of the same weights, or a PyTorch compatibility workaround cannot create commercial rights. BandScope issue #1181 owns that release blocker. ## Constraints @@ -20,9 +22,10 @@ A commercial review exposed an independent rights blocker. The upstream Demucs i - Runtime code must not silently download model artifacts. - A local model cache object is untrusted input: type, identity, byte size, and checksum evidence must be bounded before deserialization. - The private compatibility snapshot is temporary runtime authority, not a released model artifact or provenance statement. +- A PyTorch weights-only incompatibility must not silently authorize unsafe legacy pickle loading; any broader deserialization policy belongs to a fully admitted immutable release artifact and explicit Distribution decision. - The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. - Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, exact full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than MIR inference code. -- A missing, modified, oversized, size-racing, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- A missing, modified, oversized, size-racing, incompatible, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. - Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. ## RED evidence @@ -37,6 +40,8 @@ Commit `7ac4bc1d35ff736966ed556407b6ff56d03942c0` adds the resource-bound RED. A Commit `f4ef3dc86e34432936b2febb152991af70e57bd1` adds the descriptor-size continuity RED. The fixture presents a stable regular checkpoint whose descriptor preflight reports one byte less than the bytes subsequently readable from that same descriptor and forbids any Demucs resolver call. The predecessor streamed until EOF, so the extra post-preflight byte entered the private snapshot and a checksum-valid full byte sequence could still reach model resolution. The immediate descendant carries the causal fix; no hosted RED failure receipt is claimed for the intermediate head. +Commit `5789562e716d955c758a7eb728140c5fcb02f779` adds the PyTorch weights-only compatibility RED. A checksum-valid local fixture reaches the mocked Demucs resolver, which raises the same `pickle.UnpicklingError` class used when a weights-only load rejects a serialized global such as `demucs.htdemucs.HTDemucs`. The contract requires the public exception to remain `Stem separation model weights are not installed locally.` and forbids the serialized class name from leaking through that buyer-facing message. The predecessor propagated the unpickling failure. The production descendant followed immediately, so no hosted RED-failure receipt is claimed for the intermediate head. + ## Selected repair Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` established the first narrow admission guard: the production `htdemucs` checkpoint must already exist locally, be a regular non-symlink object, and unsupported/missing inputs fail with the bounded message `Stem separation model weights are not installed locally.` @@ -47,9 +52,11 @@ Commit `3662de13e1ffae2ac2337835dd6f317011e81bff` closes the mutable-cache pathn Commit `c21c6c4476f7c9ae937a24dda77eb841515ed315` bounds that compatibility snapshot to 128 MiB. The descriptor must report a positive size no greater than the ceiling before copying, so an already-oversized cache object cannot consume unbounded snapshot storage. -Commit `0d0c6c3263e9b72b5aec554c1824de3d004b5831` then binds snapshot materialization to that admitted descriptor size. The copy reads exactly `descriptor_stat.st_size` bytes, fails on an early EOF, and probes one additional byte without copying it; any post-`fstat` growth therefore fails before Demucs resolution instead of entering the snapshot. SHA-256 verification of those exact bytes against the canonical filename prefix remains required. A checksum mismatch, size violation, file-identity mismatch, size race, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. +Commit `0d0c6c3263e9b72b5aec554c1824de3d004b5831` binds snapshot materialization to that admitted descriptor size. The copy reads exactly `descriptor_stat.st_size` bytes, fails on an early EOF, and probes one additional byte without copying it; any post-`fstat` growth therefore fails before Demucs resolution instead of entering the snapshot. SHA-256 verification of those exact bytes against the canonical filename prefix remains required. A checksum mismatch, size violation, file-identity mismatch, size race, or I/O failure removes the owned snapshot and returns the same bounded model-unavailable result before Demucs deserialization. + +Commit `d395c6055bb16cfc4a76f490f16e9e6540590fae` keeps the PyTorch 2.6+ compatibility failure inside that same local-model boundary. `_load_model` catches only `pickle.UnpicklingError` from the admitted `get_model(signature, repo=snapshot_root)` call and converts it to the existing bounded local-model-unavailable `ValueError`. It does not set `weights_only=False`, broaden remote resolution, weaken snapshot checks, or treat incompatible bytes as successful model authority. Other unexpected exceptions remain visible to engineering rather than being swallowed by a broad catch. -The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must eventually replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, package placement, and update/rollback compatibility are release inputs. +The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, serialization contract, package placement, and update/rollback compatibility are release inputs. The exact packaged artifact must be demonstrated under the release PyTorch/model-loader stack rather than assuming that either `weights_only=True` or `weights_only=False` is safe or compatible. The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking legal/product prerequisite. Signal/MIR may keep this technical fail-closed boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. @@ -71,6 +78,14 @@ Rejected. It leaves a verification-to-use pathname race. Copying from the verifi Rejected. A generic maximum prevents unbounded storage but does not preserve the exact descriptor-size observation that authorized the snapshot. A file that grows after `fstat` but remains below the ceiling would contribute unadmitted bytes before checksum rejection. Exact-count copy plus an extra-byte probe keeps resource and identity evidence aligned. +### Force `weights_only=False` when current PyTorch rejects the package + +Rejected for the compatibility cache path. PyTorch documents that legacy pickle loading can execute arbitrary functions encoded by the checkpoint. The current eight-hex filename suffix and private snapshot establish local compatibility integrity, not the full release provenance needed to authorize a code-bearing object graph. Distribution may choose a native checkpoint only after exact rights/provenance, immutable full integrity evidence, loader isolation and removal conditions are documented under #1180. + +### Broadly catch every model-loader exception + +Rejected. A broad catch would hide implementation defects and incompatible scientific behavior. The current repair handles the identified `pickle.UnpicklingError` compatibility boundary while preserving fail-fast engineering visibility for unrelated failures. + ### Download or bundle the checkpoint from this MIR/Project Persistence lane Rejected. Ordinary analysis must not gain a network dependency, and model acquisition/package provenance belongs to Distribution. More importantly, #1181 currently prevents treating the upstream pretrained weights as a commercially admissible BandScope release input. @@ -91,11 +106,11 @@ Rejected. The retired heuristic is not a scientifically acceptable substitute fo ### Attack surface -The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. +The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, serialized object graphs, PyTorch loader behavior, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. ### Trust boundary -Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. +Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. PyTorch's weights-only policy is a loader security boundary, not a model-rights or scientific-acceptance statement. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. ### Realistic threats @@ -106,6 +121,8 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - a cache object is replaced between verification and model use; - a cache descriptor grows or shrinks after size preflight and changes the bytes copied into the private repository; - a corrupted canonical-name object is extremely large and exhausts temporary storage before checksum rejection; +- a legacy serialized package is incompatible with the locked PyTorch weights-only default and leaks internal class/global names through an error; +- an operator responds to that incompatibility by enabling unsafe legacy pickle loading on a merely compatibility-admitted cache object; - a technically valid upstream checkpoint is shipped or advertised commercially despite the stated scientific-purpose restriction; - a third-party mirror or converted artifact is mistaken for a new commercial license grant. @@ -118,16 +135,17 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - streaming SHA-256 verification against the canonical Demucs checksum prefix; - private temporary local repository built from the verified descriptor bytes; - explicit `repo=snapshot_root`, keeping upstream model resolution on `LocalRepo` instead of `RemoteRepo`; -- bounded failure before Demucs deserialization for missing, modified, oversized, size-racing, or otherwise inadmissible cache state; +- bounded `pickle.UnpicklingError` handling without setting `weights_only=False` or exposing serialized class details; +- bounded failure before Demucs deserialization/use for missing, modified, oversized, size-racing, or otherwise inadmissible cache state; - no heuristic-success fallback; - #1181 blocks commercial packaging/auto-download/rights claims until explicit rights or an admissible replacement exists; -- #1180 retains ownership of immutable release artifact, full digest/signature, inventory, package, signing, and updater/rollback evidence. +- #1180 retains ownership of immutable release artifact, full digest/signature, serialization policy, inventory, package, signing, and updater/rollback evidence. ### Remaining risk The current path is still a compatibility bridge around a developer/runtime torch cache, not a commercial release artifact boundary. The eight-hex suffix is truncated upstream integrity evidence, not a repository-owned full SHA-256, signature, provenance receipt, or exact package manifest. The 128 MiB ceiling is deliberately a generic safety limit rather than the exact size of an admitted release artifact. -Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. A commercially admitted release should minimize code-executing model formats where practical or bind any unavoidable format to immutable package/signature provenance and a narrow loader. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity check passes. +Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. Current PyTorch may reject legacy object graphs under the safer weights-only default; BandScope now fails closed rather than weakening that default in the compatibility path. A commercially admitted release should prefer a non-code-executing or materially narrower model format where scientifically equivalent, or bind unavoidable native deserialization to immutable package/signature provenance, an explicitly documented allowed object graph/loader policy, isolation and a removal condition. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity and compatibility check passes. ### Test points @@ -137,21 +155,23 @@ Demucs/torch deserialization still consumes a trusted technical snapshot in its - original cache pathname replaced after snapshot: private snapshot bytes remain unchanged; - checkpoint larger than the active resource ceiling: resolver call count remains zero; - descriptor preflight smaller than readable bytes: extra bytes do not enter the snapshot and resolver call count remains zero; +- weights-only incompatibility: `pickle.UnpicklingError` becomes the bounded local-model-unavailable diagnostic and serialized class names are absent from the public message; - unsupported model name and symlink/non-regular object: fail closed; - commercial release: exact rights evidence exists for the immutable artifact or the upstream weights are absent from release inputs; -- released admissible model: exact full digest/signature, exact size, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. +- released admissible model: exact full digest/signature, exact size, serialization/loader policy, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. ## Effect -Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. These controls establish a technical local-first compatibility boundary; they do not authorize commercial use of the upstream weights. +Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. The PyTorch 2.6+ weights-only compatibility failure is now bounded without silently enabling legacy pickle loading. These controls establish a technical local-first compatibility boundary; they do not prove that the upstream package loads successfully under the current locked stack, establish scientific accuracy, or authorize commercial use of the upstream weights. ## Follow-up 1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact upstream weights or select/train a commercially admissible replacement with traceable training-data/model rights. -2. Under #1180, establish the admitted model's exact version, exact byte size, full digest/signature, package location, supplemental inventory/SBOM/NOTICE, signing/notarization, and update/rollback policy. -3. Replace torch-cache compatibility discovery with a Distribution-owned immutable local artifact path/manifest. Retain the descriptor-bound/private-load principle and no-remote-fallback invariant. -4. Evaluate whether a lower-risk model serialization format can replace native checkpoint deserialization without sacrificing supported-platform behavior or scientific accuracy; document the decision and removal condition if not. -5. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics, and explicit uncertainty/claim boundaries under #770. +2. Under #1180, establish the admitted model's exact version, exact byte size, full digest/signature, serialization/loader contract, package location, supplemental inventory/SBOM/NOTICE, signing/notarization, and update/rollback policy. +3. Exercise the exact released model with the exact locked PyTorch/loader stack. If a native checkpoint is retained, document the allowed object graph and loader/isolation policy; do not treat a blanket `weights_only=False` compatibility toggle as an admission control. +4. Replace torch-cache compatibility discovery with a Distribution-owned immutable local artifact path/manifest. Retain the descriptor-bound/private-load principle and no-remote-fallback invariant. +5. Evaluate whether a lower-risk model serialization format can replace native checkpoint deserialization without sacrificing supported-platform behavior or scientific accuracy; document the decision and removal condition if not. +6. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics, and explicit uncertainty/claim boundaries under #770. ## References @@ -161,6 +181,10 @@ Rouard, S., Massa, F., & Défossez, A. (2023). Hybrid transformers for music sou Défossez, A. (2022). Re: License of pre-trained models (Issue comment 1134828611). *facebookresearch/demucs* (Issue #327). https://github.com/facebookresearch/demucs/issues/327#issuecomment-1134828611 +Gawarecki, M. (2024, November 4). BC-breaking change: `torch.load` is being flipped to use `weights_only=True` by default in the nightlies after #137602. *PyTorch Developer Mailing List*. https://dev-discuss.pytorch.org/t/bc-breaking-change-torch-load-is-being-flipped-to-use-weights-only-true-by-default-in-the-nightlies-after-137602/2573 + Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/pretrained.py Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/repo.py + +PyTorch Contributors. (2026). Serialization semantics: `torch.load` with `weights_only=True`. *PyTorch documentation*. https://docs.pytorch.org/docs/stable/notes/serialization.html#torch-load-with-weights-only-true From e4c606b6c5cbba2dc8dde7f5a0dfc93b4117338d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:19:46 +0900 Subject: [PATCH 422/448] docs(changelog): record bounded Demucs weights-only failure --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 930264d8c..5e05905a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. - Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. - Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load. +- Bound PyTorch 2.6+ weights-only checkpoint incompatibility at the admitted local-model boundary instead of leaking serialized class details or silently enabling legacy pickle loading; incompatible technical cache state now returns the existing local-model-unavailable diagnostic and remains a Distribution serialization/provenance decision. ## [0.1.3] - 2026-04-29 From 3ae3646087f6fe2ae6a9aa709025720fc40beb6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:33:58 +0900 Subject: [PATCH 423/448] test(separation): reject unsafe torch load override --- .../tests/test_demucs_local_model_boundary.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 725513fd6..5e981d0a2 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -266,3 +266,40 @@ def incompatible_weights_only_load(_name: str, **_kwargs: object) -> _FakeModel: audio_separator_module.AudioStemSeparator()._load_model() assert "HTDemucs" not in str(failure.value) + + +@pytest.mark.parametrize("unsafe_override", ["1", "y", "yes", "true", "TRUE"]) +def test_demucs_model_load_rejects_environment_override_that_disables_weights_only( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + unsafe_override: str, +) -> None: + """Do not let process environment reactivate unrestricted pickle loading.""" + checkpoint_bytes = b"environment-override-checkpoint-fixture" + checksum_prefix = hashlib.sha256(checkpoint_bytes).hexdigest()[:8] + checkpoint_name = f"955717e8-{checksum_prefix}.th" + checkpoint_root = tmp_path / "torch-hub" / "checkpoints" + checkpoint_root.mkdir(parents=True) + (checkpoint_root / checkpoint_name).write_bytes(checkpoint_bytes) + calls = {"count": 0} + + def forbidden_unsafe_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("unsafe weights-only override must fail before deserialization") + + monkeypatch.setattr( + audio_separator_module, + "_DEMUCS_LOCAL_CHECKPOINTS", + {"htdemucs": checkpoint_name}, + ) + monkeypatch.setenv("TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD", unsafe_override) + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_unsafe_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 From 0d9fb9f983a093fe3868106945677dfa58d10bba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:35:00 +0900 Subject: [PATCH 424/448] fix(separation): fail closed on unsafe torch load override --- .../separation/audio_separator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 84849cf87..420497b4b 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -30,6 +30,7 @@ and model-rights evidence remain Distribution work. - PyTorch/Demucs checkpoint incompatibility fails with the same bounded local-model diagnostic rather than exposing serialized class names or internal loader details. + Process environment cannot opt this boundary back into unrestricted pickle loading. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -70,6 +71,8 @@ _LOCAL_MODEL_UNAVAILABLE_ERROR = "Stem separation model weights are not installed locally." _ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" _ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" +_TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD_ENV = "TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD" +_TORCH_TRUTHY_ENV_VALUES = frozenset({"1", "y", "yes", "true"}) _SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 _COPY_CHUNK_BYTES = 64 * 1024 _MAX_LOCAL_DEMUCS_CHECKPOINT_BYTES = 128 * 1024 * 1024 @@ -98,6 +101,12 @@ def _valid_sha256_hex(value: object) -> bool: ) +def _unsafe_torch_pickle_override_enabled() -> bool: + """Return whether PyTorch would downgrade an implicit load to unrestricted pickle.""" + raw_value = os.environ.get(_TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD_ENV, "") + return raw_value.strip().lower() in _TORCH_TRUTHY_ENV_VALUES + + def _checkpoint_signature_and_checksum(checkpoint_name: str) -> tuple[str, str] | None: """Return the canonical Demucs signature/checksum encoded in a checkpoint name.""" stem = Path(checkpoint_name).stem @@ -328,9 +337,12 @@ def _load_model(self) -> Any: repository. Passing that repository explicitly keeps Demucs on LocalRepo and prevents RemoteRepo/network fallback or a second open of the cache path. PyTorch 2.6+ weights-only incompatibility is treated as an unavailable - admitted model; BandScope does not force unsafe legacy pickle loading here. + admitted model; BandScope does not force unsafe legacy pickle loading here, + including through PyTorch's process-level no-weights-only override. """ if self._model is None: + if _unsafe_torch_pickle_override_enabled(): + raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) try: from demucs.pretrained import ( # type: ignore[import-not-found, unused-ignore] get_model, From 28c73a37df1c05767c53e747ac7e5105e8164c88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:36:42 +0900 Subject: [PATCH 425/448] docs(traceability): record torch environment downgrade guard --- .../demucs-local-model-admission.md | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/traceability/demucs-local-model-admission.md b/docs/traceability/demucs-local-model-admission.md index 1c2f63a83..44c50656a 100644 --- a/docs/traceability/demucs-local-model-admission.md +++ b/docs/traceability/demucs-local-model-admission.md @@ -12,7 +12,9 @@ The next implementation still verified the mutable torch-cache object and then l That private snapshot introduced a separate resource-admission gap: a regular cache object with the canonical filename could be arbitrarily large. Checksum mismatch was detected only after copying the object, so corrupted local state could consume unbounded temporary storage before failing. A 128 MiB ceiling repaired the unbounded-copy case, but the copy still streamed until EOF rather than binding materialization to the descriptor size observed at `fstat`. If the file grew after preflight while remaining below the ceiling, extra bytes could still enter the private snapshot before checksum rejection. The current boundary therefore snapshots exactly the descriptor-reported byte count, rejects short reads, and rejects any byte beyond that admitted count before resolver/deserialization. -The live analysis lock now resolves `torch==2.12.1`. PyTorch changed `torch.load` so releases starting with 2.6 use `weights_only=True` by default when a custom `pickle_module` is not supplied. Native Demucs packages contain more than a plain tensor `state_dict`: upstream loading consumes serialized class/constructor metadata. A compatibility package may therefore raise `pickle.UnpicklingError` when the weights-only unpickler rejects a serialized global. That failure is security-relevant as well as operational: BandScope must not surface internal serialized class names to a buyer, silently switch to `weights_only=False`, or turn a compatibility failure into remote/model fallback. The current Signal/MIR boundary converts this incompatibility to the existing bounded local-model-unavailable diagnostic while leaving the release serialization decision with Distribution. +The live analysis lock now resolves `torch==2.12.1`. PyTorch changed `torch.load` so releases starting with 2.6 use `weights_only=True` by default when a custom `pickle_module` is not supplied. Native Demucs packages contain more than a plain tensor `state_dict`: upstream loading consumes serialized class/constructor metadata. A compatibility package may therefore raise `pickle.UnpicklingError` when the weights-only unpickler rejects a serialized global. That failure is security-relevant as well as operational: BandScope must not surface internal serialized class names to a buyer, silently switch to `weights_only=False`, or turn a compatibility failure into remote/model fallback. The Signal/MIR boundary converts this incompatibility to the existing bounded local-model-unavailable diagnostic while leaving the release serialization decision with Distribution. + +PyTorch also documents a process-level override, `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`, that makes an implicit `torch.load` use `weights_only=False` when the call site did not pass the argument. Upstream Demucs 4.x uses that implicit form for native packages. Relying only on PyTorch's safer default therefore left a downgrade path outside BandScope's model-admission code: a truthy inherited environment value could reactivate unrestricted pickle loading before any BandScope exception boundary ran. The current loader rejects that unsafe override before Demucs import/resolution or checkpoint deserialization and returns the same bounded local-model-unavailable diagnostic. A commercial review exposed an independent rights blocker. The upstream Demucs issue about distributing pretrained models commercially received an explicit maintainer response that the model weights are not covered by the MIT code license and are provided only for scientific purposes. Technical integrity, local-only loading, a third-party mirror, conversion of the same weights, or a PyTorch compatibility workaround cannot create commercial rights. BandScope issue #1181 owns that release blocker. @@ -22,10 +24,10 @@ A commercial review exposed an independent rights blocker. The upstream Demucs i - Runtime code must not silently download model artifacts. - A local model cache object is untrusted input: type, identity, byte size, and checksum evidence must be bounded before deserialization. - The private compatibility snapshot is temporary runtime authority, not a released model artifact or provenance statement. -- A PyTorch weights-only incompatibility must not silently authorize unsafe legacy pickle loading; any broader deserialization policy belongs to a fully admitted immutable release artifact and explicit Distribution decision. +- A PyTorch weights-only incompatibility must not silently authorize unsafe legacy pickle loading; process environment must not downgrade an implicit Demucs `torch.load` to `weights_only=False`; any broader deserialization policy belongs to a fully admitted immutable release artifact and explicit Distribution decision. - The upstream pretrained Demucs weights must not be bundled, auto-downloaded, or represented as commercially licensed unless an explicit commercial-use/redistribution grant covering the exact artifact is obtained. - Model artifacts are supply-chain inputs: usage/redistribution rights, provenance, exact full integrity evidence, package placement, SBOM/supplemental inventory coverage, signing and update/rollback behavior belong to Distribution rather than MIR inference code. -- A missing, modified, oversized, size-racing, incompatible, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. +- A missing, modified, oversized, size-racing, incompatible, environment-downgraded, or commercially inadmissible model must fail safely rather than fall back to the retired FFT mask or claim successful separation. - Unit fixtures may mock a model boundary; release/scientific acceptance still requires rights-cleared real decoded audio and an actually admissible released model artifact. ## RED evidence @@ -42,6 +44,8 @@ Commit `f4ef3dc86e34432936b2febb152991af70e57bd1` adds the descriptor-size conti Commit `5789562e716d955c758a7eb728140c5fcb02f779` adds the PyTorch weights-only compatibility RED. A checksum-valid local fixture reaches the mocked Demucs resolver, which raises the same `pickle.UnpicklingError` class used when a weights-only load rejects a serialized global such as `demucs.htdemucs.HTDemucs`. The contract requires the public exception to remain `Stem separation model weights are not installed locally.` and forbids the serialized class name from leaking through that buyer-facing message. The predecessor propagated the unpickling failure. The production descendant followed immediately, so no hosted RED-failure receipt is claimed for the intermediate head. +Commit `3ae3646087f6fe2ae6a9aa709025720fc40beb6c` adds the environment-downgrade RED. For every documented truthy form of `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` plus uppercase `TRUE`, a checksum-valid local fixture forbids the Demucs resolver from being called. The predecessor entered `get_model`, so an upstream implicit `torch.load` could have observed the unsafe process override. The production descendant followed immediately, so no hosted RED-failure receipt is claimed for the intermediate head. + ## Selected repair Commit `61b629baaef0d6da15967fe272b9d9f109d18eaf` established the first narrow admission guard: the production `htdemucs` checkpoint must already exist locally, be a regular non-symlink object, and unsupported/missing inputs fail with the bounded message `Stem separation model weights are not installed locally.` @@ -56,6 +60,8 @@ Commit `0d0c6c3263e9b72b5aec554c1824de3d004b5831` binds snapshot materialization Commit `d395c6055bb16cfc4a76f490f16e9e6540590fae` keeps the PyTorch 2.6+ compatibility failure inside that same local-model boundary. `_load_model` catches only `pickle.UnpicklingError` from the admitted `get_model(signature, repo=snapshot_root)` call and converts it to the existing bounded local-model-unavailable `ValueError`. It does not set `weights_only=False`, broaden remote resolution, weaken snapshot checks, or treat incompatible bytes as successful model authority. Other unexpected exceptions remain visible to engineering rather than being swallowed by a broad catch. +Commit `0d9fb9f983a093fe3868106945677dfa58d10bba` rejects PyTorch's documented no-weights-only process override before Demucs import/resolution. The guard recognizes the documented truthy values case-insensitively and does not mutate global process environment or rewrite upstream loader code. An unsafe inherited override therefore cannot turn the admitted compatibility path into unrestricted pickle deserialization; it fails with the existing bounded model-unavailable diagnostic. + The 128 MiB ceiling is a defensive compatibility resource limit, not a claim about the exact commercial artifact. Distribution #1180 must replace this cache-compatibility assumption with an immutable admitted artifact whose exact byte size, full digest/signature, serialization contract, package placement, and update/rollback compatibility are release inputs. The exact packaged artifact must be demonstrated under the release PyTorch/model-loader stack rather than assuming that either `weights_only=True` or `weights_only=False` is safe or compatible. The commercial-rights finding is not treated as a code bug that can be patched by changing a package label. #1181 makes the upstream pretrained weights a release-blocking legal/product prerequisite. Signal/MIR may keep this technical fail-closed boundary in Draft, but Distribution must not turn those weights into a commercial BandScope artifact without rights evidence. @@ -82,6 +88,10 @@ Rejected. A generic maximum prevents unbounded storage but does not preserve the Rejected for the compatibility cache path. PyTorch documents that legacy pickle loading can execute arbitrary functions encoded by the checkpoint. The current eight-hex filename suffix and private snapshot establish local compatibility integrity, not the full release provenance needed to authorize a code-bearing object graph. Distribution may choose a native checkpoint only after exact rights/provenance, immutable full integrity evidence, loader isolation and removal conditions are documented under #1180. +### Rely on PyTorch's default without guarding its environment override + +Rejected. Upstream Demucs does not pass `weights_only` explicitly at the native package call site, and PyTorch documents that a truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` changes such calls to `weights_only=False`. A secure default is not an invariant if inherited process state can reverse it. BandScope rejects the downgrade before entering the third-party loader rather than modifying global environment or patching Demucs. + ### Broadly catch every model-loader exception Rejected. A broad catch would hide implementation defects and incompatible scientific behavior. The current repair handles the identified `pickle.UnpicklingError` compatibility boundary while preserving fail-fast engineering visibility for unrelated failures. @@ -106,11 +116,11 @@ Rejected. The retired heuristic is not a scientifically acceptable substitute fo ### Attack surface -The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, serialized object graphs, PyTorch loader behavior, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. +The model-loading boundary crosses the local Python process into third-party Demucs/torch deserialization. Cache pathname state, opened model bytes, descriptor size, temporary snapshots, serialized object graphs, inherited PyTorch loader environment, loader behavior, and release model artifacts are security-, availability-, scientific-integrity-, and supply-chain-sensitive inputs. ### Trust boundary -Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. PyTorch's weights-only policy is a loader security boundary, not a model-rights or scientific-acceptance statement. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. +Signal/MIR may consume a technically admitted local model for Draft analysis, but it does not own remote acquisition, commercial-use/redistribution rights, or release packaging. The private snapshot binds one load to the regular descriptor, its admitted byte count, and verified local bytes; it does not make those bytes commercially admissible. The eight-hex checksum is upstream compatibility integrity evidence, not BandScope release provenance. PyTorch's weights-only policy is a loader security boundary, not a model-rights or scientific-acceptance statement, and BandScope requires that inherited process state cannot downgrade that policy on this implicit upstream call. #1180 owns Distribution artifact delivery and #1181 owns the pretrained-weight rights blocker. ### Realistic threats @@ -122,7 +132,8 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - a cache descriptor grows or shrinks after size preflight and changes the bytes copied into the private repository; - a corrupted canonical-name object is extremely large and exhausts temporary storage before checksum rejection; - a legacy serialized package is incompatible with the locked PyTorch weights-only default and leaks internal class/global names through an error; -- an operator responds to that incompatibility by enabling unsafe legacy pickle loading on a merely compatibility-admitted cache object; +- inherited `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` state silently turns an implicit upstream load into unrestricted pickle deserialization; +- an operator responds to compatibility failure by enabling unsafe legacy pickle loading on a merely compatibility-admitted cache object; - a technically valid upstream checkpoint is shipped or advertised commercially despite the stated scientific-purpose restriction; - a third-party mirror or converted artifact is mistaken for a new commercial license grant. @@ -135,8 +146,9 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu - streaming SHA-256 verification against the canonical Demucs checksum prefix; - private temporary local repository built from the verified descriptor bytes; - explicit `repo=snapshot_root`, keeping upstream model resolution on `LocalRepo` instead of `RemoteRepo`; +- reject a truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` before the upstream loader can deserialize a native package; - bounded `pickle.UnpicklingError` handling without setting `weights_only=False` or exposing serialized class details; -- bounded failure before Demucs deserialization/use for missing, modified, oversized, size-racing, or otherwise inadmissible cache state; +- bounded failure before Demucs deserialization/use for missing, modified, oversized, size-racing, environment-downgraded, or otherwise inadmissible cache state; - no heuristic-success fallback; - #1181 blocks commercial packaging/auto-download/rights claims until explicit rights or an admissible replacement exists; - #1180 retains ownership of immutable release artifact, full digest/signature, serialization policy, inventory, package, signing, and updater/rollback evidence. @@ -145,7 +157,7 @@ Signal/MIR may consume a technically admitted local model for Draft analysis, bu The current path is still a compatibility bridge around a developer/runtime torch cache, not a commercial release artifact boundary. The eight-hex suffix is truncated upstream integrity evidence, not a repository-owned full SHA-256, signature, provenance receipt, or exact package manifest. The 128 MiB ceiling is deliberately a generic safety limit rather than the exact size of an admitted release artifact. -Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. Current PyTorch may reject legacy object graphs under the safer weights-only default; BandScope now fails closed rather than weakening that default in the compatibility path. A commercially admitted release should prefer a non-code-executing or materially narrower model format where scientifically equivalent, or bind unavoidable native deserialization to immutable package/signature provenance, an explicitly documented allowed object graph/loader policy, isolation and a removal condition. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity and compatibility check passes. +Demucs/torch deserialization still consumes a trusted technical snapshot in its native checkpoint format. Current PyTorch may reject legacy object graphs under the safer weights-only default; BandScope now fails closed rather than weakening that default or allowing PyTorch's documented no-weights-only environment override to weaken it on the implicit Demucs call. A commercially admitted release should prefer a non-code-executing or materially narrower model format where scientifically equivalent, or bind unavoidable native deserialization to immutable package/signature provenance, an explicitly documented allowed object graph/loader policy, isolation and a removal condition. The upstream pretrained `htdemucs` weights remain blocked for commercial release by #1181 even if every technical integrity and compatibility check passes. ### Test points @@ -156,19 +168,20 @@ Demucs/torch deserialization still consumes a trusted technical snapshot in its - checkpoint larger than the active resource ceiling: resolver call count remains zero; - descriptor preflight smaller than readable bytes: extra bytes do not enter the snapshot and resolver call count remains zero; - weights-only incompatibility: `pickle.UnpicklingError` becomes the bounded local-model-unavailable diagnostic and serialized class names are absent from the public message; +- truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`: resolver/deserialization call count remains zero and the bounded local-model-unavailable diagnostic is returned; - unsupported model name and symlink/non-regular object: fail closed; - commercial release: exact rights evidence exists for the immutable artifact or the upstream weights are absent from release inputs; - released admissible model: exact full digest/signature, exact size, serialization/loader policy, inventory, package/signing/notarization, rollback, and offline Windows/macOS real-audio acceptance are linked. ## Effect -Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. The PyTorch 2.6+ weights-only compatibility failure is now bounded without silently enabling legacy pickle loading. These controls establish a technical local-first compatibility boundary; they do not prove that the upstream package loads successfully under the current locked stack, establish scientific accuracy, or authorize commercial use of the upstream weights. +Ordinary missing-model execution no longer begins an implicit model download. Modified, oversized, or size-racing cache objects fail before Demucs resolution, and the model resolver consumes a private snapshot derived from exactly the descriptor byte count BandScope admitted rather than reopening the mutable torch-cache pathname or accepting later growth. The PyTorch 2.6+ weights-only compatibility failure is bounded without silently enabling legacy pickle loading, and inherited PyTorch environment state cannot opt the implicit Demucs load back into unrestricted pickle mode. These controls establish a technical local-first compatibility boundary; they do not prove that the upstream package loads successfully under the current locked stack, establish scientific accuracy, or authorize commercial use of the upstream weights. ## Follow-up 1. Resolve #1181: obtain explicit commercial-use/redistribution rights for the exact upstream weights or select/train a commercially admissible replacement with traceable training-data/model rights. 2. Under #1180, establish the admitted model's exact version, exact byte size, full digest/signature, serialization/loader contract, package location, supplemental inventory/SBOM/NOTICE, signing/notarization, and update/rollback policy. -3. Exercise the exact released model with the exact locked PyTorch/loader stack. If a native checkpoint is retained, document the allowed object graph and loader/isolation policy; do not treat a blanket `weights_only=False` compatibility toggle as an admission control. +3. Exercise the exact released model with the exact locked PyTorch/loader stack. If a native checkpoint is retained, document the allowed object graph and loader/isolation policy; do not treat a blanket `weights_only=False` compatibility toggle or environment override as an admission control. 4. Replace torch-cache compatibility discovery with a Distribution-owned immutable local artifact path/manifest. Retain the descriptor-bound/private-load principle and no-remote-fallback invariant. 5. Evaluate whether a lower-risk model serialization format can replace native checkpoint deserialization without sacrificing supported-platform behavior or scientific accuracy; document the decision and removal condition if not. 6. Exercise the exact packaged artifact on supported Windows and macOS using rights-cleared real audio, recognized source-separation metrics, and explicit uncertainty/claim boundaries under #770. @@ -187,4 +200,6 @@ Meta Platforms, Inc. (2023). `demucs.pretrained`: loading pretrained models. *fa Meta Platforms, Inc. (2023). `demucs.repo`: remote and local model repositories. *facebookresearch/demucs*. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/repo.py +PyTorch Contributors. (2026). Miscellaneous environment variables. *PyTorch documentation*. https://docs.pytorch.org/docs/stable/miscellaneous_environment_variables.html + PyTorch Contributors. (2026). Serialization semantics: `torch.load` with `weights_only=True`. *PyTorch documentation*. https://docs.pytorch.org/docs/stable/notes/serialization.html#torch-load-with-weights-only-true From dc593266aba304aaf74d76c1a9bd7f6078fd3141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:37:23 +0900 Subject: [PATCH 426/448] docs(changelog): record torch environment downgrade guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e05905a5..d9d3939a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. - Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load. - Bound PyTorch 2.6+ weights-only checkpoint incompatibility at the admitted local-model boundary instead of leaking serialized class details or silently enabling legacy pickle loading; incompatible technical cache state now returns the existing local-model-unavailable diagnostic and remains a Distribution serialization/provenance decision. +- Reject PyTorch's `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` downgrade at the local Demucs boundary, so inherited process environment cannot turn the upstream implicit checkpoint load back into unrestricted pickle deserialization. ## [0.1.3] - 2026-04-29 From 4d0b16b6ace0bad9ef5b91fc996034b1ae4001c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:44:57 +0900 Subject: [PATCH 427/448] test(separation): reject torch backend autoload --- .../tests/test_demucs_local_model_boundary.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/services/analysis-engine/tests/test_demucs_local_model_boundary.py b/services/analysis-engine/tests/test_demucs_local_model_boundary.py index 5e981d0a2..e500bd2b7 100644 --- a/services/analysis-engine/tests/test_demucs_local_model_boundary.py +++ b/services/analysis-engine/tests/test_demucs_local_model_boundary.py @@ -303,3 +303,27 @@ def forbidden_unsafe_lookup(_name: str, **_kwargs: object) -> _FakeModel: audio_separator_module.AudioStemSeparator()._load_model() assert calls["count"] == 0 + + +def test_demucs_model_load_rejects_backend_autoload_environment( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not let torch import auto-load out-of-tree backend extensions.""" + calls = {"count": 0} + + def forbidden_lookup(_name: str, **_kwargs: object) -> _FakeModel: + calls["count"] += 1 + raise AssertionError("backend autoload must fail before Demucs or torch import") + + monkeypatch.setenv("TORCH_DEVICE_BACKEND_AUTOLOAD", "1") + _install_fake_runtime( + monkeypatch, + torch_hub_dir=str(tmp_path / "torch-hub"), + get_model=forbidden_lookup, + ) + + with pytest.raises(ValueError, match="model weights are not installed locally"): + audio_separator_module.AudioStemSeparator()._load_model() + + assert calls["count"] == 0 From 000fdb57e212be5f08a328bb677be4e0ae1ebb24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:46:01 +0900 Subject: [PATCH 428/448] fix(separation): fail closed on torch backend autoload --- .../separation/audio_separator.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index 420497b4b..0537ada10 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -30,7 +30,8 @@ and model-rights evidence remain Distribution work. - PyTorch/Demucs checkpoint incompatibility fails with the same bounded local-model diagnostic rather than exposing serialized class names or internal loader details. - Process environment cannot opt this boundary back into unrestricted pickle loading. + Process environment cannot opt this boundary back into unrestricted pickle loading + or automatic import of out-of-tree torch backend extensions. - Does not log or persist raw audio, separated stems, or full source paths. - Fails with bounded, filename-scoped errors so callers can surface a safe failure without leaking local directory structure. @@ -72,6 +73,7 @@ _ADMITTED_AUDIO_BYTES_ENV = "BANDSCOPE_ADMITTED_AUDIO_BYTES" _ADMITTED_AUDIO_SHA256_ENV = "BANDSCOPE_ADMITTED_AUDIO_SHA256" _TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD_ENV = "TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD" +_TORCH_DEVICE_BACKEND_AUTOLOAD_ENV = "TORCH_DEVICE_BACKEND_AUTOLOAD" _TORCH_TRUTHY_ENV_VALUES = frozenset({"1", "y", "yes", "true"}) _SNAPSHOT_MEMORY_BYTES = 8 * 1024 * 1024 _COPY_CHUNK_BYTES = 64 * 1024 @@ -107,6 +109,11 @@ def _unsafe_torch_pickle_override_enabled() -> bool: return raw_value.strip().lower() in _TORCH_TRUTHY_ENV_VALUES +def _torch_backend_autoload_enabled() -> bool: + """Return whether importing torch would auto-load out-of-tree backend extensions.""" + return os.environ.get(_TORCH_DEVICE_BACKEND_AUTOLOAD_ENV, "").strip() == "1" + + def _checkpoint_signature_and_checksum(checkpoint_name: str) -> tuple[str, str] | None: """Return the canonical Demucs signature/checksum encoded in a checkpoint name.""" stem = Path(checkpoint_name).stem @@ -338,10 +345,11 @@ def _load_model(self) -> Any: and prevents RemoteRepo/network fallback or a second open of the cache path. PyTorch 2.6+ weights-only incompatibility is treated as an unavailable admitted model; BandScope does not force unsafe legacy pickle loading here, - including through PyTorch's process-level no-weights-only override. + including through PyTorch's process-level no-weights-only override or + out-of-tree backend autoload. """ if self._model is None: - if _unsafe_torch_pickle_override_enabled(): + if _unsafe_torch_pickle_override_enabled() or _torch_backend_autoload_enabled(): raise ValueError(_LOCAL_MODEL_UNAVAILABLE_ERROR) try: from demucs.pretrained import ( # type: ignore[import-not-found, unused-ignore] From 3fc5a87c08028bc1817ff75c43cc6bd10816cc5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:47:09 +0900 Subject: [PATCH 429/448] docs(traceability): bind pytorch runtime environment --- .../pytorch-runtime-environment-admission.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/traceability/pytorch-runtime-environment-admission.md diff --git a/docs/traceability/pytorch-runtime-environment-admission.md b/docs/traceability/pytorch-runtime-environment-admission.md new file mode 100644 index 000000000..68f2eec65 --- /dev/null +++ b/docs/traceability/pytorch-runtime-environment-admission.md @@ -0,0 +1,112 @@ +# PyTorch runtime-environment admission traceability + +Status: Draft + +## Problem + +BandScope's local Demucs compatibility path is intended to enter PyTorch only after BandScope has decided that the local model boundary is admissible. Current PyTorch documentation exposes two inherited process-environment controls that can widen execution before or during that third-party boundary: + +- `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` makes an implicit `torch.load` use `weights_only=False`. Demucs 4.x uses an implicit `torch.load` for native packages, so this can reactivate unrestricted pickle deserialization. +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` makes `import torch` automatically import out-of-tree backend extensions. The Demucs path imports torch as part of model loading, so inherited process state can expand the code-import surface before BandScope has admitted the checkpoint. + +Neither variable is model evidence. An inherited shell, launcher, test harness, or host environment must not be able to weaken BandScope's local model-admission boundary. + +## Constraints + +- The Draft `htdemucs` path is CPU-oriented and does not require out-of-tree backend autoload. +- BandScope must not mutate the parent process environment as a hidden compatibility workaround. +- The compatibility path must fail closed before importing Demucs/torch when a documented unsafe environment control is active. +- Failure must use the existing bounded local-model-unavailable diagnostic and must not reveal loader internals. +- A future accelerator/backend design must be explicit, packaged, versioned, and admitted by Distribution rather than enabled through inherited autoload state. +- These runtime controls do not establish commercial model rights, immutable artifact provenance, or scientific acceptance. #1180 and #1181 retain those owner boundaries. + +## RED evidence + +Commit `3ae3646087f6fe2ae6a9aa709025720fc40beb6c` sets each documented truthy form of `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` and requires the Demucs resolver call count to remain zero. The predecessor entered `get_model`, so the upstream implicit `torch.load` could observe the downgrade. Fix `0d9fb9f983a093fe3868106945677dfa58d10bba` followed immediately; no hosted RED-failure receipt is claimed for the intermediate head. + +Commit `4d0b16b6ace0bad9ef5b91fc996034b1ae4001c8` sets `TORCH_DEVICE_BACKEND_AUTOLOAD=1` and requires the model resolver call count to remain zero. PyTorch documents that this value causes out-of-tree backend extensions to be imported when `torch` is imported. The predecessor had no pre-import guard for this environment control. Fix `000fdb57e212be5f08a328bb677be4e0ae1ebb24` followed immediately; no hosted RED-failure receipt is claimed for the intermediate head. + +## Selected repair + +The Signal/MIR loader checks the two documented unsafe inherited environment conditions before importing `demucs.pretrained`: + +- documented truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` values are rejected case-insensitively; +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` is rejected exactly as documented by PyTorch. + +The repair does not clear or rewrite global environment variables, monkeypatch PyTorch, enable a different backend, broaden remote resolution, or change the existing private checkpoint snapshot. It simply refuses to enter the third-party loader when inherited runtime state would widen the code-execution surface. + +## Alternatives considered + +### Delete the environment variables inside BandScope + +Rejected. Mutating process-global environment is surprising stateful behavior and can race with other code in the process. The local compatibility path does not own the user's shell or parent launcher configuration. + +### Permit backend autoload because the current model uses CPU + +Rejected. CPU use makes the autoload unnecessary, not safe. Automatic import of installed out-of-tree backend extensions expands the execution surface without contributing to BandScope's current CPU inference contract. + +### Depend on PyTorch's safer defaults + +Rejected. PyTorch explicitly documents environment controls that alter those defaults. A security boundary that can be reversed by inherited process state is not a stable BandScope invariant. + +### Add an implicit accelerator fallback + +Rejected. Accelerator support must be explicit and reproducible across supported platform packages. An inherited environment flag is not a versioned capability contract and cannot substitute for CPU/MLX/CUDA/OpenCL parity evidence. + +## Security Notes + +### Attack surface + +The attack surface includes BandScope's analysis child process, inherited environment variables, Python module import, installed PyTorch out-of-tree backend extensions, Demucs model resolution, and native checkpoint deserialization. + +### Trust boundary + +The analysis child process may inherit ordinary environment state, but that state is not trusted to authorize broader Python/native code loading. Signal/MIR owns the fail-closed pre-import compatibility guard. Distribution owns which backends, model artifacts, loader versions, signatures, and package contents are admitted in a commercial release. + +### Realistic threats + +- a parent launcher sets `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD`, causing an implicit Demucs `torch.load` to fall back to unrestricted pickle; +- a parent launcher sets `TORCH_DEVICE_BACKEND_AUTOLOAD=1`, causing `import torch` to import installed out-of-tree backend extensions before model admission; +- a compatibility workaround becomes an undocumented production dependency and later differs across Windows/macOS packages; +- a bounded local-model failure leaks serialized class or backend implementation details to the buyer. + +### Mitigations + +- reject documented unsafe environment states before Demucs/torch import; +- keep the existing bounded local-model-unavailable diagnostic; +- do not mutate process-global environment or silently enable another backend; +- preserve the private descriptor-bound local model snapshot and local-only resolver; +- require Distribution-owned explicit backend/model package admission for release behavior; +- require supported-platform tests to exercise negative inherited-environment cases for any retained implicit third-party loader behavior. + +### Remaining risk + +This guard only covers the documented PyTorch environment controls that materially affect the current local model-loading path. A commercially admitted release still needs a complete environment/package execution model, immutable model provenance, an explicit serialization policy, and isolation/removal conditions for any native checkpoint deserialization. The current upstream pretrained weights also remain commercially blocked by #1181 independent of runtime hardening. + +### Test points + +- each documented truthy `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` value fails before resolver/deserialization; +- `TORCH_DEVICE_BACKEND_AUTOLOAD=1` fails before Demucs/torch import and resolver use; +- normal environment state still reaches the existing local-only snapshot path; +- failure text remains the bounded local-model-unavailable diagnostic; +- exact packaged Windows/macOS release tests cover inherited environment downgrade/autoload cases if the native PyTorch loader remains; +- no environment guard is counted as MIR accuracy, real-audio scientific acceptance, or commercial-rights evidence. + +## Effect + +Inherited PyTorch process state can no longer opt BandScope's Draft local Demucs path into unrestricted implicit pickle loading or automatic import of out-of-tree backend extensions. The loader fails before entering Demucs/torch when either documented widening condition is active. This narrows runtime execution but does not make the upstream checkpoint commercially admissible or scientifically accepted. + +## Follow-up + +1. #1180 must define the release model/backend environment contract, including negative tests for loader environment downgrades and any intentionally packaged accelerator extensions. +2. Prefer an artifact/loader format with a materially narrower code-execution surface when scientific parity is demonstrated. +3. Keep #1181 as the independent commercial-rights prerequisite for upstream pretrained weights. +4. Run rights-cleared real-audio source-separation acceptance on exact supported Windows/macOS packages under #770 before any release-quality claim. + +## References + +PyTorch Contributors. (2025, June 17). *Miscellaneous environment variables*. PyTorch documentation. https://docs.pytorch.org/docs/main/miscellaneous_environment_variables.html + +PyTorch Contributors. (2026). *Serialization semantics: torch.load with weights_only=True*. PyTorch documentation. https://docs.pytorch.org/docs/stable/notes/serialization.html#torch-load-with-weights-only-true + +Meta Platforms, Inc. (2023). *demucs.states: model serialization/loading*. facebookresearch/demucs. https://github.com/facebookresearch/demucs/blob/v4.0.1/demucs/states.py From 4676650f21980ef743d9b9936443b6131aca223f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 03:47:49 +0900 Subject: [PATCH 430/448] docs(changelog): record torch backend autoload guard --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d3939a7..3bca8e7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ - Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC. - Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load. - Bound PyTorch 2.6+ weights-only checkpoint incompatibility at the admitted local-model boundary instead of leaking serialized class details or silently enabling legacy pickle loading; incompatible technical cache state now returns the existing local-model-unavailable diagnostic and remains a Distribution serialization/provenance decision. -- Reject PyTorch's `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` downgrade at the local Demucs boundary, so inherited process environment cannot turn the upstream implicit checkpoint load back into unrestricted pickle deserialization. +- Reject PyTorch runtime-environment widening at the local Demucs boundary: `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` cannot turn the upstream implicit checkpoint load back into unrestricted pickle deserialization, and `TORCH_DEVICE_BACKEND_AUTOLOAD=1` cannot auto-import out-of-tree backend extensions before model admission. ## [0.1.3] - 2026-04-29 From f5db38aa423bcad81490d95b4d2129bcbdd52fd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:03:06 +0900 Subject: [PATCH 431/448] docs(security): structure cross-platform plan notes --- ...026-03-10-bandscope-cross-platform-build.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-03-10-bandscope-cross-platform-build.md b/docs/plans/2026-03-10-bandscope-cross-platform-build.md index 3a02aa3a5..8a0b9f5de 100644 --- a/docs/plans/2026-03-10-bandscope-cross-platform-build.md +++ b/docs/plans/2026-03-10-bandscope-cross-platform-build.md @@ -8,40 +8,44 @@ **Tech Stack:** GitHub Actions, npm, uv, Rust stable toolchain, Python packaging sanity, zip artifacts, SHA-256 checksums. -**Security Notes:** Cross-platform builds are supply-chain and release-integrity controls. The harness must fail if Windows or macOS coverage, artifact upload, checksum generation, or required-check intent drifts out of policy. +## Security Notes -## Attack surface +Cross-platform builds are supply-chain and release-integrity controls. The harness must fail if Windows or macOS coverage, artifact upload, checksum generation, or required-check intent drifts out of policy. + +### Attack surface - Windows and macOS packaging paths - native dependencies and bundled binaries per OS - release artifact generation and upload -## Trust boundary +### Trust boundary - target-OS build workers in GitHub Actions act as release-path verifiers - branch protections depend on named Windows and macOS build jobs -## Mitigations +### Mitigations - add dedicated Windows and macOS build jobs - upload per-OS artifacts and checksums on PR, push, tag, and release events - document required-check intent in repo docs and verify workflow coverage locally -## Test points +### Test points - local supply-chain verification covers workflow presence and trigger scope - workflow uploads artifact and checksum for both OSes - intended required checks include both OS build jobs -## Realistic threats +### Realistic threats - platform-specific bundle assets can be missing even when the Rust shell compiles locally - release upload credentials can be over-scoped if build and publish concerns share the same job -## Remaining risk +### Remaining risk - notarization and signing remain outside the bootstrap harness until platform credentials exist +## Implementation tasks + --- ### Task 1: Add cross-platform build policy docs From 707d68009235c548f2de20550646ce965e46dd70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:03:26 +0900 Subject: [PATCH 432/448] docs(security): structure harness plan notes --- docs/plans/2026-03-10-bandscope-harness.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-03-10-bandscope-harness.md b/docs/plans/2026-03-10-bandscope-harness.md index b114c3196..625fd28fd 100644 --- a/docs/plans/2026-03-10-bandscope-harness.md +++ b/docs/plans/2026-03-10-bandscope-harness.md @@ -8,37 +8,41 @@ **Tech Stack:** npm workspaces, Vite, React, Vitest, Tauri scaffold files, Python 3.12+, uv, pytest, ruff, mypy, Dependabot, CycloneDX JSON SBOM, GitHub Actions SHA pinning. -**Security Notes:** The harness must keep security guidance visible and fail-fast. Future work that touches files, URLs, subprocesses, IPC, WebView, updates, models, or cache/export behavior must include a `Security Notes` section and avoid generic exec/read/write capabilities. +## Security Notes -## Attack surface +The harness must keep security guidance visible and fail-fast. Future work that touches files, URLs, subprocesses, IPC, WebView, updates, models, or cache/export behavior must include a `Security Notes` section and avoid generic exec/read/write capabilities. + +### Attack surface - repo docs and plans that define future file, URL, subprocess, IPC, WebView, model, and update behavior -## Trust boundary +### Trust boundary - future product work crosses user-input, process, IPC, storage, and network boundaries even in a local-first app -## Mitigations +### Mitigations - keep security policy in repo docs, not only in chat - fail plans that omit `Security Notes` - fail obvious dangerous implementation patterns early -## Test points +### Test points - docs presence checks - `Security Notes` structure checks - security pattern checks in quickcheck -## Realistic threats +### Realistic threats - future contributors can copy unsafe bootstrap defaults into production features - local checks can silently miss risky workflow or release-script drift if scope is too narrow -## Remaining risk +### Remaining risk - desktop runtime constraints remain provisional until real IPC and backend flows exist +## Implementation tasks + --- ### Task 1: Add repository docs and root config From 87d6cd74a2cb42004e111a12552b5dcb73e3ce6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:03:44 +0900 Subject: [PATCH 433/448] docs(security): structure supply-chain plan notes --- .../plans/2026-03-10-bandscope-supply-chain.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-03-10-bandscope-supply-chain.md b/docs/plans/2026-03-10-bandscope-supply-chain.md index bd028984a..071cb5adf 100644 --- a/docs/plans/2026-03-10-bandscope-supply-chain.md +++ b/docs/plans/2026-03-10-bandscope-supply-chain.md @@ -8,42 +8,46 @@ **Tech Stack:** npm workspaces, uv lock, Cargo lock, Dependabot, GitHub Actions, CycloneDX JSON SBOM, supplemental JSON inventory. -**Security Notes:** Supply-chain workflows are part of the public attack surface. The harness must fail if lockfiles, workflow pinning, dependency review, audits, SBOM generation, or supplemental inventory drift out of policy. +## Security Notes -## Attack surface +Supply-chain workflows are part of the public attack surface. The harness must fail if lockfiles, workflow pinning, dependency review, audits, SBOM generation, or supplemental inventory drift out of policy. + +### Attack surface - dependency manifests and lockfiles - GitHub Actions and third-party actions - bundled binaries and model artifacts - release assets and uploaded SBOMs -## Trust boundary +### Trust boundary - package-manager graphs do not fully cover binaries and model artifacts - GitHub workflows and release assets are externally visible supply-chain surfaces -## Mitigations +### Mitigations - commit lockfiles and pin workflow actions by SHA - add dependency review, audit, and SBOM workflows - keep supplemental component inventory in machine-readable form - document intended required checks for develop and main -## Test points +### Test points - local supply-chain verification script - quickcheck path includes supply-chain verification - workflows trigger on develop, main, PR, tag, and release-related events -## Realistic threats +### Realistic threats - over-broad workflow permissions can let PR-modified code affect release surfaces - missing bundled-binary inventory can hide shipped assets outside package-manager graphs -## Remaining risk +### Remaining risk - GitHub-native security signals still depend on repository settings and service availability outside repo control +## Implementation tasks + --- ### Task 1: Add supply-chain policy docs and inventory From 87690cdfe79dbb066e9ea4acf15b4b6e46b5fd4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:04:21 +0900 Subject: [PATCH 434/448] docs(security): record analysis dispatch threats --- docs/traceability/analysis-dispatch-source-revalidation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/traceability/analysis-dispatch-source-revalidation.md b/docs/traceability/analysis-dispatch-source-revalidation.md index d588a373f..11d6d29de 100644 --- a/docs/traceability/analysis-dispatch-source-revalidation.md +++ b/docs/traceability/analysis-dispatch-source-revalidation.md @@ -65,6 +65,13 @@ The renderer-visible project id remains a selector only. Native `LocalAudioPubli The repair combines checks with distinct purposes. Native re-admission confirms the app-owned project/source contract immediately before queue admission. Python then creates a private content snapshot and verifies the same identity at the consuming decode boundary. The decoder reads the verified snapshot itself, eliminating the previous check-then-reopen byte gap. Content-addressed work roots prevent same-path/same-size cache aliasing without duplicating hash computation in Python. +### Realistic threats + +- the app-owned source path is rebound to different same-size bytes after native revalidation but before Python opens it; +- a child receives a partial or malformed evidence pair and silently falls back to an unverified decode path; +- process-global evidence mutation causes concurrent jobs to consume another project's source identity; +- same-path/same-size replacement aliases an existing analysis or stem-work cache namespace. + ### Safe failure Missing native identity, project mismatch, native re-open failure, malformed or partial child evidence, growth, truncation, or same-size mutation fails before separation/model work. Native paths and OS diagnostics are not returned as buyer-facing detail. Existing direct/manual library callers with no native evidence retain the compatibility path; production desktop local-audio jobs always provide evidence. From eee919d9918be8999caa8218f1ec37fa3b229d00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:04:51 +0900 Subject: [PATCH 435/448] docs(traceability): align v2 preference security notes --- .../project-format-v2-playback-preference.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/traceability/project-format-v2-playback-preference.md b/docs/traceability/project-format-v2-playback-preference.md index ec5dc61bd..332e95b00 100644 --- a/docs/traceability/project-format-v2-playback-preference.md +++ b/docs/traceability/project-format-v2-playback-preference.md @@ -55,7 +55,7 @@ Version 2 introduced: The v2 decision survives in current v3 as the same closed `preferences.selectedPlaybackSource` domain and deterministic historical migration rule. Tauri `save_project`/`load_project` now admit/return the typed current document rather than the old song-only compatibility view, so the historical bridge gap described above has been superseded. -Version 3 adds a separate optional path-free app-owned `sourceReference`. That field is deliberately not a playback authority and is not inferred for v2/v1/legacy projects. Current process-restart audio reopen is still incomplete because Resource Admission has not yet materialized the full mix under the app-owned project namespace and reconstructed a fresh bootstrap from that reference. +Version 3 adds a separate optional path-free app-owned `sourceReference`. That field is deliberately not a playback authority and is not inferred for v2/v1/legacy projects. #970 now re-admits the app-owned full mix on restart against the persisted size and SHA-256 evidence and binds production analysis decode to a verified private byte snapshot. The remaining Active Player work is to reconcile durable `selectedPlaybackSource` intent with fresh Full mix/current-stem audible authorities under #1160. ## Security Notes @@ -67,6 +67,17 @@ Version 3 adds a separate optional path-free app-owned `sourceReference`. That f The historical v2 envelope and current preference DTO use `deny_unknown_fields`; selection is a five-value enum; the rehearsal song remains strict typed data. Unknown root/preference values and literal `bandscope-playback://...` values fail before publication. Current v3 adds a separately typed source-reference boundary rather than weakening this preference contract. +### Mitigations + +Keep durable playback intent as the closed five-value semantic, reject runtime capability strings and unknown fields at both renderer and native admission, migrate evidence-free historical projects deterministically to `full_mix`, and require fresh Resource Admission/Active Player authority before a stored stem preference becomes playable. + +### Realistic threats + +- a crafted project stores a filesystem path or revocable playback URL as if it were durable playback truth; +- a future or malformed preference token is accepted and later interpreted differently by renderer and native code; +- a historical project is migrated by guessing a stem selection that the artifact never recorded; +- a valid persisted stem preference is replayed after restart without checking whether that stem is currently admitted and audible. + ### Logging and privacy Preference/migration errors are bounded validation errors and need not echo project paths, song/collaboration content, media URLs, credentials, or audio metadata. The preference itself contains no locator. @@ -77,4 +88,4 @@ Preference/migration errors are bounded validation errors and need not echo proj ### Remaining risk -The preference schema itself is no longer the process-restart blocker. Remaining work is owned by the v3/resource-admission path: app-owned full-mix materialization and re-admission, mounted Save/Reopen composition, fresh stem-authority resolution/fallback, stronger content identity where required, autosave/backup/startup recovery UX, migration receipts and downgrade behavior, descriptor-bound parent authority, and exhaustive interruption/power-loss evidence. Exact-head cross-platform CI and independent review remain mandatory before merge/release. +The preference schema and full-mix restart/content identity path are no longer the blocker. Remaining work is primarily Active Player and release evidence: reconcile the durable selection against freshly admitted Full mix and current stem artifacts, fail closed to Full mix when a preferred stem is unavailable, complete mounted Save/Reopen and audible E2E on supported Windows/macOS packages, and retain crash/recovery/downgrade evidence and independent exact-head review before merge/release. From 21fb89b08043aed0df3439579ddc5ea5be63b0ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:05:44 +0900 Subject: [PATCH 436/448] docs(traceability): align v3 source security contract --- .../project-format-v3-source-reference.md | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/traceability/project-format-v3-source-reference.md b/docs/traceability/project-format-v3-source-reference.md index 2b5704294..a9eb5d1ef 100644 --- a/docs/traceability/project-format-v3-source-reference.md +++ b/docs/traceability/project-format-v3-source-reference.md @@ -12,7 +12,7 @@ The first v3 source-reference draft narrowed location authority correctly but re - Historical projects must migrate deterministically. Missing evidence must stay missing rather than being inferred. - A durable source handle must not contain a user filesystem path, WebView storage key, generation token, or runtime playback URL. - Renderer and file input are untrusted and must remain passive JSON data. -- The source handle has to be sufficient for later native re-admission to derive and verify an app-owned artifact without cross-service SQL or another writable authority. +- The source handle has to be sufficient for native re-admission to derive and verify an app-owned artifact without cross-service SQL or another writable authority. - The v3 format is still Draft/unreleased work in #970, so tightening the v3 source-reference contract before merge is preferable to publishing an underspecified same-version schema and then maintaining it as compatibility debt. ## RED evidence @@ -49,11 +49,11 @@ The contract accepts only: - a positive byte length. The renderer additionally requires a JavaScript safe integer so it cannot silently round persisted byte evidence; - a canonical lowercase 64-hex-character SHA-256 digest of the app-owned source bytes. -`fileSizeBytes` remains useful as a bounded preflight and diagnostic signal but is not accepted as content identity. `contentSha256` is the durable equality check that later Resource Admission must recompute over the re-opened app-owned artifact before creating fresh runtime authority. FIPS 180-4 defines SHA-256 as part of the Secure Hash Standard; NIST's current CAVP secure-hashing material, updated in August 2026, continues to list SHA-256 under FIPS 180-4. NIST has announced a future revision of FIPS 180-4, but that revision has not replaced the current final standard. +`fileSizeBytes` remains useful as a bounded preflight and diagnostic signal but is not accepted as content identity. `contentSha256` is the durable equality check that Resource Admission recomputes over the re-opened app-owned artifact before creating fresh runtime authority. FIPS 180-4 defines SHA-256 as part of the Secure Hash Standard; NIST's current CAVP secure-hashing material, updated in August 2026, continues to list SHA-256 under FIPS 180-4. NIST has announced a future revision of FIPS 180-4, but that revision has not replaced the current final standard. The field is optional because v2/v1/legacy projects cannot prove that an app-owned source artifact exists. Their ordered migration writes version 3 with no invented reference. `selectedPlaybackSource` remains independent: it is rehearsal intent, while `sourceReference` identifies only the app-owned full-mix artifact required to rebuild native availability. -The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. A future reopen path must derive the artifact below the validated app-owned project root rather than trust a persisted path, re-check link/reparse and file identity at access time, verify size, recompute SHA-256, and only then re-run audio admission. These references justify the threat model; they do not constitute evidence that re-admission is already implemented. +The path-free shape is also a security boundary, not merely a portability choice. CWE-22 treats attacker-influenced relative/absolute pathnames as a path-traversal class, while CWE-59 covers file access that follows a link or shortcut to an unintended resource. The current reopen path derives the fixed artifact below the validated app-owned project root rather than trusting a persisted path, rejects linked/reparsed final components and substituted roots, verifies bounded size and SHA-256, and only then restores native source identity. This does not yet make every ancestor lookup descriptor-bound against concurrent replacement; that residual filesystem-identity risk remains separately tracked. ## Rejected alternatives @@ -81,7 +81,7 @@ The path-free shape is also a security boundary, not merely a portability choice - `c2117f2a41e2c1db84aba6332c069dda59b5cad2` — requires canonical lowercase SHA-256 content identity in the native v3 source-reference contract. - `7e853c5d6c40a35128afcf356536d2ca147ad109` — requires the same SHA-256 evidence in renderer admission and keeps digest/property inspection passive and fail closed. -Hosted exact-head checks are authoritative for repository GREEN; predecessor results are not transferable. The test-first/root-cause record also follows the released NIST SSDF 1.1 principle of integrating secure-development practices into the SDLC and addressing vulnerability root causes rather than treating a passing downstream check as the sole control. NIST published SSDF 1.2 only as SP 800-218 Rev. 1 Initial Public Draft in December 2025; this traceability therefore treats v1.1 as the released reference and the v1.2 draft as non-normative tracking input. +Subsequent #970 descendants adopted Resource Admission #866, inject retained publication identity into v3 Save, re-admit the exact app-owned bytes on restart, and pass the retained identity through the native-to-analysis boundary so production decode consumes a verified private byte snapshot. Hosted exact-head checks remain authoritative for repository GREEN; predecessor results are not transferable. The test-first/root-cause record also follows the released NIST SSDF 1.1 principle of integrating secure-development practices into the SDLC and addressing vulnerability root causes rather than treating a passing downstream check as the sole control. NIST published SSDF 1.2 only as SP 800-218 Rev. 1 Initial Public Draft in December 2025; this traceability therefore treats v1.1 as the released reference and the v1.2 draft as non-normative tracking input. ## Security Notes @@ -93,9 +93,21 @@ Hosted exact-head checks are authoritative for repository GREEN; predecessor res Native and TypeScript boundaries reject unknown source-reference fields. Project ids use the existing BandScope minted-id grammar. Artifact names are derived from the admitted extension and cannot contain path traversal. The extension is closed to the existing audio allowlist. Byte evidence must be positive; the renderer additionally rejects unsafe integers. `contentSha256` must be exactly 64 lowercase hexadecimal characters. The string is evidence to be verified, not trusted merely because its syntax is valid. +### Mitigations + +Project Persistence stores only the path-free identity tuple, derives the fixed app-owned artifact from the validated project namespace, and delegates byte truth to Resource Admission. Restart re-admission rechecks project/root constraints, regular/no-link semantics, exact size and SHA-256 before native identity is restored. Production analysis then carries that retained evidence to the child process and decodes a verified private snapshot rather than trusting a later pathname reopen. + +### Realistic threats + +- a crafted `.bscope` attempts path traversal or substitutes an arbitrary artifact name or extension; +- an app-owned source is replaced with different same-size bytes after the project was saved; +- a symlink/reparse point or substituted project root redirects reopen outside the intended project namespace; +- a renderer fabricates digest/size evidence and tries to impersonate native Resource Admission state; +- a valid durable preference is mistaken for current playback authority without re-admitting the corresponding Full mix or stem. + ### Safe failure -Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. Re-admission must fail closed if the derived artifact is absent, non-regular, linked/reparsed, has the wrong size, has a SHA-256 mismatch, or fails audio decode/admission checks. CWE-59 specifically makes link resolution before file access part of the threat model, so lexical containment plus matching digest syntax is not sufficient acceptance evidence. +Malformed references fail before project publication or before a reopened document is accepted by the renderer bridge. Historical inputs migrate without a reference rather than fabricating an authority. Re-admission fails closed if the derived artifact is absent, non-regular, linked/reparsed at the governed boundary, has the wrong size, has a SHA-256 mismatch, or fails the applicable admission/decode path. CWE-59 specifically makes link resolution before file access part of the threat model, so lexical containment plus matching digest syntax is not sufficient acceptance evidence. ### Logging and privacy @@ -103,11 +115,11 @@ The durable reference intentionally excludes the original local path and origina ### Test points -`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, unknown `sourcePath` rejection, and canonical SHA-256 requirements. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary, including digest presence and canonical representation. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. +`project_format_v3_source_reference.rs` covers current round-trip, v2 migration without invention, project-id/path/artifact/extension/size rejection, unknown `sourcePath` rejection, and canonical SHA-256 requirements. `project_format_v2_playback_preference.rs` keeps legacy/v1/v2 compatibility explicit while asserting current-version output and absent invented source evidence. `projectDocumentBridge.test.ts` covers the renderer/native payload boundary, including digest presence and canonical representation. `projectDocument.plainRecord.test.ts` covers passive record semantics and getter/proxy rejection. Current Project Persistence integration tests additionally cover restart re-admission, root/link substitution, byte mutation/growth/truncation, and the native retained-identity handoff. ### Remaining risk -Version 3 is a schema/admission foundation, not completed source re-admission. Current Resource Admission still stores bootstrap source information in process memory and uses the selected external source path. The next causal slice must materialize the admitted full mix under the app-owned project namespace, compute `contentSha256` from the bytes that were actually published, write `sourceReference` only after publication and digest calculation succeed, and reconstruct a fresh bootstrap from the validated reference on reopen. Reopen must recompute SHA-256 before issuing playback authority. Cleanup/retention policy for app-owned audio, crash injection during materialization, and rights-cleared Windows/macOS real-audio acceptance remain required before this path can be called release-ready. +The v3 schema, retained Save handoff, restart exact-content re-admission and production analysis byte continuity are implemented in the Draft #970 ancestry, but release evidence is incomplete. Higher-ancestor filesystem identity is not fully descriptor-bound against concurrent replacement on all supported platforms. Active Player #1160 must still reconcile persisted `selectedPlaybackSource` with fresh Full mix/current-stem audible authorities and fail closed to Full mix when the preferred stem is absent. Cleanup/retention, mounted Save/Reopen, crash/power-loss, downgrade/application rollback, and rights-cleared Windows/macOS real-audio acceptance remain required before release readiness. ## References From 9388e8c26b14ad7f7ffdb7b2e0170f526228564e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:06:17 +0900 Subject: [PATCH 437/448] docs(traceability): align project IPC security notes --- docs/traceability/project-v2-ipc-bridge.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/traceability/project-v2-ipc-bridge.md b/docs/traceability/project-v2-ipc-bridge.md index 17f629aea..06864a460 100644 --- a/docs/traceability/project-v2-ipc-bridge.md +++ b/docs/traceability/project-v2-ipc-bridge.md @@ -49,16 +49,20 @@ Persisting the opaque playback URL was rejected because its authority is intenti **Mitigations.** Exact-key checks are exception-safe; plain-record checks reject custom prototypes; persisted values are read through own enumerable data-property descriptors; getters and descriptor traps do not become project data. The closed five-value preference, `parseRehearsalSong`, Rust `deny_unknown_fields`, bounded reads and crash-safe publication remain layered controls. Version 3's `sourceReference` is separately typed and path-free rather than being smuggled into this preference field. Browser fallback rejects project Save/Load instead of creating a second in-memory persistence truth. Local result-to-project association is captured at analysis submission rather than read from whatever source happens to be selected at Save time. +**Realistic threats.** A crafted renderer object can attempt to execute getters or Proxy traps during validation; stale global project selection can bind one result to another aggregate; a browser preview can falsely report persistence that never wrote bytes; a renderer can try to submit path/digest authority that belongs to native Resource Admission; or a reopened project can carry valid durable intent whose current audio/stem authority no longer exists. + **Test points.** Bridge tests cover all five preferences, load round trip, runtime-authority/unknown-field rejection, source-reference admission, invalid path-shaped reference fields, renderer-authored source-reference rejection before IPC, browser-preview Save fail-closed behavior, explicit project-id selector forwarding, native retained-identity lookup/injection, and mounted local-audio analysis → Save identity continuity. `projectDocument.plainRecord.test.ts` covers custom prototypes, proxy traps, accessor non-invocation, null-prototype acceptance and ordinary JSON records. Native format tests cover historical migration and current v3 source-reference shape. **Logging/privacy.** Rejected object contents, trap text, local paths and project payloads are not forwarded as validation output. The public renderer error remains bounded rather than echoing attacker-controlled exceptions. +**Remaining risk.** The bridge and full-mix restart/content-identity path are now present in #970, but the persisted playback preference is still intent rather than fresh audible authority. #1160 must re-admit current Full mix/stem resources, reconcile the stored selection, and fall back to Full mix when a preferred stem is absent. Supported Windows/macOS mounted Save/Reopen, crash/recovery, downgrade/rollback and real-audio E2E remain release evidence gaps. + ## Current effect and remaining risk The desktop IPC and Project Persistence now speak the same typed current document; the historical song-only Tauri gap is closed. Current writes are v3, not v2. The document can carry both a stable playback preference and an optional path-free app-owned `sourceReference`. Browser preview no longer reports a successful project Save when it has no durable file authority. -Resource Admission #866 materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 ordinarily adopted that implementation, exposes the typed `project_source_reference_from_publication_identity` ACL, and now injects the exact retained identity into v3 Save when the mounted local-analysis result supplies its minted project id. +Resource Admission #866 materializes OS-selected local audio into app-owned `project_root/source.`, verifies the published bytes against a bounded size+SHA-256 receipt, builds a path-free `LocalAudioPublicationIdentity`, and retains that identity in native state before renderer bootstrap authority is returned. Project Persistence #970 ordinarily adopted that implementation, exposes the typed `project_source_reference_from_publication_identity` ACL, injects the exact retained identity into v3 Save when the mounted local-analysis result supplies its minted project id, and on restart re-admits the app-owned artifact against persisted size and SHA-256 evidence before native source identity is restored. Production analysis then decodes a private snapshot verified against the retained evidence rather than trusting a later pathname reopen. -The principal source-persistence gap has therefore moved to restart. `load_project` validates and returns the persisted v3 `sourceReference`, but it does not yet resolve only the app-owned artifact, re-establish regular/no-link containment, re-check bounded size and SHA-256 plus applicable decode/admission, reconstruct a fresh bootstrap, and retain a fresh runtime identity before returning playable authority. #1160 may resolve persisted `selectedPlaybackSource` only after that fresh authority exists and must fail closed to Full mix when a preferred stem is no longer available. +The principal remaining product gap is therefore Active Player authority reconciliation rather than durable full-mix identity. #1160 may resolve persisted `selectedPlaybackSource` only after fresh Full mix and current stem authorities exist and must fail closed to Full mix when a preferred stem is no longer available. -Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery, downgrade/application rollback, restart source re-admission and independent exact-head review evidence remain release gates. +Packaged Windows/macOS Save/Reopen, crash/power-loss, autosave/recovery, downgrade/application rollback, current-stem re-admission, audible E2E and independent exact-head review evidence remain release gates. From 349d67dc28adda5a5369d88b4d6b3c6df7d7cdf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:12:35 +0900 Subject: [PATCH 438/448] docs(architecture): align local audio authority flow --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c8e5d152b..751068872 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,9 +111,9 @@ Last updated: 2026-09-06 - Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals. - Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas. - The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands. -- Current Project Persistence writes a strict `projectFormatVersion: 3` envelope around the validated rehearsal song, closed Active Player preference, and optional path-free app-owned audio `sourceReference`; legacy raw-song, v1, and v2 inputs remain readable through ordered migration, while Resource Admission still owns the unfinished app-owned full-mix materialization/re-admission needed after process restart. Source/derived/decision/handoff expansion, autosave/recovery UX, and volatile runtime authority remain follow-up work under #962 rather than parallel stores. +- Current Project Persistence writes a strict `projectFormatVersion: 3` envelope around the validated rehearsal song, closed Active Player preference, and optional path-free app-owned audio `sourceReference`; legacy raw-song, v1, and v2 inputs remain readable through ordered migration. Resource Admission materializes the admitted local source as the fixed app-owned `source.` artifact, verifies publication byte identity, and retains a path-free native identity. Project Persistence injects that identity into Save and re-admits the exact size and SHA-256 on restart; production analysis revalidates the retained identity and decodes a verified private byte snapshot. Source/derived/decision/handoff expansion, autosave/recovery UX, and fresh Active Player audible authority remain follow-up work under #962/#961 rather than parallel stores. - Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener. -- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase. +- Local audio intake validates an OS-selected source in Rust, enforces the canonical resource policy, publishes a no-clobber app-owned `source.` copy under the minted project aggregate, verifies the published bytes, and exposes only bounded bootstrap/path-free identity evidence to downstream Project Persistence and analysis consumers. - Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace. - Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy. - Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs. From 8feb0c35a209f2b0a503e23c9cb5112071c1d906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 04:18:06 +0900 Subject: [PATCH 439/448] test(project): bind dispatch opener closure --- .../desktop/src-tauri/tests/analysis_dispatch_revalidation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs index 90c2e46e9..52bdec5f5 100644 --- a/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs +++ b/apps/desktop/src-tauri/tests/analysis_dispatch_revalidation.rs @@ -67,7 +67,7 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { let refreshed = revalidate_local_audio_bootstrap_for_analysis( &bootstrap(&project_root), &retained_identity(), - fs::File::open, + |path| fs::File::open(path), ) .expect("unchanged app-owned bytes should regain dispatch authority"); assert_eq!(refreshed.source.source_path, source_path.to_string_lossy()); @@ -89,7 +89,7 @@ fn analysis_dispatch_revalidates_current_app_owned_bytes() { let error = revalidate_local_audio_bootstrap_for_analysis( &bootstrap(&project_root), &retained_identity(), - fs::File::open, + |path| fs::File::open(path), ) .expect_err("same-size mutation must fail before analysis dispatch"); assert_eq!( From dbc3dd7d4db6a0e2408a2bc945c93d08d758133f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 05:02:30 +0900 Subject: [PATCH 440/448] test(project): accept generic restart adapter signature --- .../desktop/src-tauri/tests/local_audio_publication_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs index ad3c6f02b..af3ce3839 100644 --- a/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs +++ b/apps/desktop/src-tauri/tests/local_audio_publication_contract.rs @@ -123,7 +123,7 @@ fn project_load_re_admits_persisted_source_before_returning_document() { let load_command = &load_tail[..load_end]; assert!( - source.contains("fn restore_project_source_after_restart("), + source.contains("fn restore_project_source_after_restart"), "restart needs one native adapter that restores source authority from persisted evidence" ); assert!( From 8a947537072a60fc5081daef88c30eb04762cfb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 05:03:11 +0900 Subject: [PATCH 441/448] fix(desktop): remove stale rehearsal parser import --- apps/desktop/src/lib/analysis.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 0c820c92b..e2b51935e 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -7,7 +7,6 @@ import { parseAnalysisJobStatus, parseAnalysisJobRequest, parseProjectBootstrapSummary, - parseRehearsalSong, type AnalysisJobError, type AnalysisJobRequest, type AnalysisJobStatus, From 67b1ebcd509d3cc73f119bdea1eeca255c482fb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 05:06:50 +0900 Subject: [PATCH 442/448] test(security): format Security Notes policy regressions --- .../tests/test_security_notes_policy.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index 432897c73..89a77da5c 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -1,7 +1,7 @@ """Regression coverage for the repository Security Notes documentation contract.""" -import runpy from pathlib import Path +import runpy REPO_ROOT = Path(__file__).resolve().parents[3] @@ -13,7 +13,14 @@ def test_security_notes_section_stops_at_next_peer_heading() -> None: """Do not let unrelated peer sections satisfy missing Security Notes evidence.""" - document = """# Example\n\n## Security Notes\n\nAttack surface and trust boundary are defined here.\n\n## Operations\n\nMitigations, test points, realistic threats, and remaining risk are documented elsewhere.\n""" + document = ( + "# Example\n\n" + "## Security Notes\n\n" + "Attack surface and trust boundary are defined here.\n\n" + "## Operations\n\n" + "Mitigations, test points, realistic threats, and remaining risk are " + "documented elsewhere.\n" + ) section = security_notes_section(document) @@ -27,7 +34,15 @@ def test_security_notes_section_stops_at_next_peer_heading() -> None: def test_security_notes_section_stops_when_parent_section_resumes() -> None: """Keep a nested Security Notes section from consuming a later parent section.""" - document = """# Example\n\n## Design\n\n### Security Notes\n\nAttack surface and trust boundary are defined here.\n\n## Operations\n\nMitigations, test points, realistic threats, and remaining risk are documented elsewhere.\n""" + document = ( + "# Example\n\n" + "## Design\n\n" + "### Security Notes\n\n" + "Attack surface and trust boundary are defined here.\n\n" + "## Operations\n\n" + "Mitigations, test points, realistic threats, and remaining risk are " + "documented elsewhere.\n" + ) section = security_notes_section(document) From f9553f01481eed8f36cceddf177053261db85735 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:03:58 +0900 Subject: [PATCH 443/448] test(ci): fix security notes import order --- services/analysis-engine/tests/test_security_notes_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index 89a77da5c..37556957f 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -1,7 +1,7 @@ """Regression coverage for the repository Security Notes documentation contract.""" -from pathlib import Path import runpy +from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] From 542791ad85623cd200276e0ed0ea3ea2c73c3d5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:07:45 +0900 Subject: [PATCH 444/448] test(ci): make Security Notes imports Ruff-canonical --- services/analysis-engine/tests/test_security_notes_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index 37556957f..c10d42c83 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -1,11 +1,11 @@ """Regression coverage for the repository Security Notes documentation contract.""" -import runpy from pathlib import Path +from runpy import run_path REPO_ROOT = Path(__file__).resolve().parents[3] -SECURITY_NOTES_CHECK = runpy.run_path( +SECURITY_NOTES_CHECK = run_path( str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py") ) security_notes_section = SECURITY_NOTES_CHECK["security_notes_section"] From bab276e8cb5384cb9e47b970af2e659284337d7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:17:05 +0900 Subject: [PATCH 445/448] test(ci): match Ruff import-block spacing --- services/analysis-engine/tests/test_security_notes_policy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index c10d42c83..c0e9b9e1d 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -3,7 +3,6 @@ from pathlib import Path from runpy import run_path - REPO_ROOT = Path(__file__).resolve().parents[3] SECURITY_NOTES_CHECK = run_path( str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py") From 2d2e5a907aff2db0a78512bb814c716bd7993c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:53:27 +0900 Subject: [PATCH 446/448] style(ci): terminate temporal analyzer source canonically --- .../analysis-engine/src/bandscope_analysis/temporal/analyzer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py index 584ef6c99..ee8aec403 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/analyzer.py @@ -150,4 +150,4 @@ def analyze(self, audio_path: str | Path) -> TemporalFeatures: except Exception as error: logger.error("Temporal analysis failed (%s).", type(error).__name__) - raise ValueError(_safe_temporal_failure_message(error)) from error \ No newline at end of file + raise ValueError(_safe_temporal_failure_message(error)) from error From 50837f122ffc289a433aebab4267cc63d969ca52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:54:57 +0900 Subject: [PATCH 447/448] style(ci): terminate transcription source canonically --- .../analysis-engine/src/bandscope_analysis/transcription/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/transcription/api.py b/services/analysis-engine/src/bandscope_analysis/transcription/api.py index 8be079c08..3318da65e 100644 --- a/services/analysis-engine/src/bandscope_analysis/transcription/api.py +++ b/services/analysis-engine/src/bandscope_analysis/transcription/api.py @@ -171,4 +171,4 @@ def _merge_adjacent_equal_pitches(events: list[NoteEvent]) -> list[NoteEvent]: ) else: merged.append(event) - return merged \ No newline at end of file + return merged From 46478c4aadb4f4ad4a5c4ed9821a6456be0db09d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 06:55:12 +0900 Subject: [PATCH 448/448] style(ci): apply Ruff layout to Security Notes test --- services/analysis-engine/tests/test_security_notes_policy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/analysis-engine/tests/test_security_notes_policy.py b/services/analysis-engine/tests/test_security_notes_policy.py index c0e9b9e1d..b8f45006c 100644 --- a/services/analysis-engine/tests/test_security_notes_policy.py +++ b/services/analysis-engine/tests/test_security_notes_policy.py @@ -4,9 +4,7 @@ from runpy import run_path REPO_ROOT = Path(__file__).resolve().parents[3] -SECURITY_NOTES_CHECK = run_path( - str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py") -) +SECURITY_NOTES_CHECK = run_path(str(REPO_ROOT / "scripts" / "checks" / "verify_security_notes.py")) security_notes_section = SECURITY_NOTES_CHECK["security_notes_section"]