From cb251f78ad8470627d68bd4c18d97c705ddc83d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:19:22 +0900 Subject: [PATCH 001/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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/146] 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 c2cc5bbeda6628fa9999401d6b0d228cb9b6bb9c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 13:41:01 +0900 Subject: [PATCH 084/146] 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 505a595d481f8ba03abd8d13e7c17202918c833f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 15:51:45 +0900 Subject: [PATCH 085/146] 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 ecc2279c7fe42fc021e39180bd27f5f325a2eb74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:17:51 +0900 Subject: [PATCH 086/146] 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 087/146] 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 088/146] 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 089/146] 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 090/146] 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 091/146] 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 092/146] 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 093/146] 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 094/146] 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 095/146] 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 096/146] 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 c5cc94feda19f8c75504abab9731717963309932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:53:54 +0900 Subject: [PATCH 097/146] 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 9852265b900ddfcce5431665a567fd01af6b92ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:19:22 +0900 Subject: [PATCH 098/146] 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 099/146] 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 100/146] 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 101/146] 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 102/146] 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 103/146] 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 104/146] 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 105/146] 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 106/146] 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 107/146] 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 108/146] 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 109/146] 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 110/146] 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 111/146] 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 112/146] 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 113/146] 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 114/146] 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 115/146] 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 116/146] 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 117/146] 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 118/146] 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 119/146] 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 120/146] 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 121/146] 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 122/146] 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 123/146] 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 124/146] 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 125/146] 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 126/146] 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 127/146] 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 128/146] 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 129/146] 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 130/146] 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 131/146] 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 132/146] 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 133/146] 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 134/146] 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 135/146] 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 136/146] 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 137/146] 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 138/146] 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 cbfa967b16e94f2d84940665ce38537075a8ce41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:28:36 +0900 Subject: [PATCH 139/146] 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 140/146] 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 141/146] 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 142/146] 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 143/146] 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 144/146] 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 145/146] 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 146/146] 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