From cb251f78ad8470627d68bd4c18d97c705ddc83d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:19:22 +0900 Subject: [PATCH 01/84] 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 02/84] 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 03/84] 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 04/84] 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 05/84] 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 06/84] 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 07/84] 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 08/84] 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 09/84] 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 10/84] 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 11/84] 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 12/84] 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 13/84] 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 14/84] 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 15/84] 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 16/84] 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 17/84] 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 18/84] 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 19/84] 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 20/84] 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 21/84] 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 22/84] 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 23/84] 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 24/84] 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 25/84] 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 26/84] 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 27/84] 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 28/84] 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 29/84] 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 30/84] 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 31/84] 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 32/84] 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 33/84] 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 34/84] 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 35/84] 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 36/84] 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 37/84] 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 38/84] 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 39/84] 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 40/84] 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 41/84] 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 42/84] 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 43/84] 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 44/84] 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 45/84] 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 46/84] 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 47/84] 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 48/84] 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 49/84] 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 50/84] 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 51/84] 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 52/84] 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 53/84] 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 54/84] 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 55/84] 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 56/84] 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 57/84] 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 58/84] 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 59/84] 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 60/84] 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 61/84] 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 62/84] 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 63/84] 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 64/84] 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 65/84] 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 66/84] 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 67/84] 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 68/84] 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 69/84] 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 70/84] 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 71/84] 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 72/84] 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 73/84] 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 74/84] 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 75/84] 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 76/84] 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 77/84] 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 78/84] 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 79/84] 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 80/84] 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 81/84] 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 82/84] 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 83/84] 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 84/84] 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")