Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
151 commits
Select commit Hold shift + click to select a range
cb251f7
test(audio): define canonical resource policy contract
seonghobae Aug 16, 2026
3e96ed6
feat(audio): add canonical resource policy
seonghobae Aug 16, 2026
f83a1ba
test(score): require bounded validated PDF reads
seonghobae Aug 16, 2026
d659d9d
refactor(core): expose bounded score reader module
seonghobae Aug 16, 2026
fc1af87
refactor(core): preserve public API through root module
seonghobae Aug 16, 2026
1df5fe0
fix(score): bound stored PDF reads before allocation
seonghobae Aug 16, 2026
521fe12
fix(score): use bounded native PDF reader
seonghobae Aug 16, 2026
ca20dc5
style(score): keep bounded reader rustfmt-clean
seonghobae Aug 16, 2026
e6d31ee
docs(changelog): record bounded stored-score reads
seonghobae Aug 16, 2026
051e39d
fix(core): preserve desktop-core package contract
seonghobae Aug 16, 2026
c11af77
test(score): cover non-file stored score reads
seonghobae Aug 16, 2026
f86e266
test(score): prove same-descriptor growth detection
seonghobae Aug 16, 2026
6aa0098
test(audio): require policy parity at orchestration and decode bounda…
seonghobae Aug 16, 2026
ee3b48c
fix(audio): add one-sample decode probe to resource policy
seonghobae Aug 16, 2026
a982e7f
fix(audio): bind temporal decode to canonical resource policy
seonghobae Aug 16, 2026
38b3fb6
fix(audio): enforce canonical policy before stem inference
seonghobae Aug 16, 2026
ed6e4f7
fix(audio): apply canonical byte policy at request preflight
seonghobae Aug 16, 2026
e7671de
docs(audio): record resource-boundary evidence
seonghobae Aug 16, 2026
9549647
docs(changelog): record canonical audio resource bounds
seonghobae Aug 16, 2026
b35bc12
docs(security): bind local audio to canonical resource policy
seonghobae Aug 16, 2026
710ed16
test(audio): reject resource-policy arithmetic overflow
seonghobae Aug 16, 2026
6e80527
fix(audio): fail closed on extreme sample-rate arithmetic
seonghobae Aug 16, 2026
572333e
style(audio): keep policy bounds formatter-clean
seonghobae Aug 16, 2026
adbfad0
test(audio): reject unrepresentable policy and decoder values
seonghobae Aug 16, 2026
428db0a
fix(audio): make resource policy arithmetic fully fail closed
seonghobae Aug 16, 2026
84f60b3
docs(audio): record checked policy arithmetic
seonghobae Aug 16, 2026
e42ff5c
test(audio): reject oversized desktop selection at the bridge
seonghobae Aug 16, 2026
05fbbb3
test(audio): enforce encoded-byte parity for imported sources
seonghobae Aug 16, 2026
b23e51a
fix(audio): enforce encoded-byte parity at desktop bridge
seonghobae Aug 16, 2026
509c18b
docs(changelog): record desktop audio-policy parity
seonghobae Aug 16, 2026
9823e0e
fix(changelog): restore full release history after parity note drift
seonghobae Aug 16, 2026
f631049
fix(ci): format audio resource policy regressions
seonghobae Aug 16, 2026
0ba0593
test(audio): reject fractional encoded byte metadata
seonghobae Aug 16, 2026
ceb7f71
fix(audio): require integral encoded byte metadata
seonghobae Aug 16, 2026
84f7691
test(audio): require native encoded-byte admission
seonghobae Aug 16, 2026
da676cb
chore(audio): stack native resource policy on bounded core root
seonghobae Aug 16, 2026
0488c8f
fix(audio): enforce native encoded-byte ceiling
seonghobae Aug 16, 2026
fbc7d7d
fix(audio): export native resource policy
seonghobae Aug 16, 2026
e61e858
fix(audio): enforce native bootstrap byte ceiling
seonghobae Aug 16, 2026
3f976e5
docs(audio): record native intake enforcement
seonghobae Aug 16, 2026
2b9d5e3
test(audio): require YouTube download to use canonical 100 MiB policy
cursoragent Aug 16, 2026
19064f4
fix(audio): abort YouTube downloads at the canonical encoded-byte cei…
cursoragent Aug 16, 2026
7b3b1e7
docs(audio): record YouTube download-time policy evidence
cursoragent Aug 16, 2026
d1a75c9
test(audio): cover fail-closed resource admission branches
seonghobae Aug 16, 2026
1f3fdb8
merge(audio): consolidate YouTube admission repair into canonical #866
seonghobae Aug 16, 2026
5e8fa77
fix(audio): delete owned YouTube partials on in-flight abort
cursoragent Aug 16, 2026
6df13ae
style(audio): wrap zero-byte decoder regression
seonghobae Aug 16, 2026
6d3b9b2
test(audio): reject malformed YouTube duration metadata
seonghobae Aug 16, 2026
86f7272
fix(audio): reject malformed YouTube duration metadata
seonghobae Aug 16, 2026
bc0fc98
docs(changelog): record YouTube duration metadata guard
seonghobae Aug 16, 2026
ec64aa7
test(audio): bound decoded buffer memory
seonghobae Aug 16, 2026
104573a
fix(audio): enforce decoded memory budget
seonghobae Aug 16, 2026
c6d9368
docs(audio): record decoded memory admission
seonghobae Aug 16, 2026
f60eb17
docs(audio): record decoded memory budget
seonghobae Aug 16, 2026
aeb42da
test(youtube): cover fail-closed duration conversion
seonghobae Aug 16, 2026
f35f1d4
test(youtube): avoid exceptional float subclasses
seonghobae Aug 17, 2026
f5f0c3d
test(youtube): reject noncanonical numeric metadata
seonghobae Aug 17, 2026
f4cee9e
fix(youtube): reject non-canonical duration numerics
seonghobae Aug 17, 2026
69cdf8b
docs(changelog): record strict duration metadata type gate
seonghobae Aug 17, 2026
3ea49d3
fix(youtube): remove unreachable duration conversion branch
seonghobae Aug 17, 2026
97490c7
test(youtube): require post-download duration revalidation
seonghobae Aug 17, 2026
d58c24e
test(youtube): model owned cleanup on duration drift
seonghobae Aug 17, 2026
d3e2792
fix(youtube): revalidate duration after download
seonghobae Aug 17, 2026
1c85058
test(privacy): fail on temporal path disclosure
seonghobae Aug 17, 2026
4a6e269
fix(privacy): redact temporal analysis failure diagnostics
seonghobae Aug 17, 2026
6d3c388
test(privacy): align temporal diagnostics with redaction contract
seonghobae Aug 17, 2026
aa0191c
test(security): reject foreign YouTube download paths
seonghobae Aug 18, 2026
48d06cf
fix(security): bind completed YouTube path to import cache
seonghobae Aug 18, 2026
85424c9
docs(security): record YouTube completed-path authority
seonghobae Aug 18, 2026
7eca596
docs(changelog): record YouTube completed-path containment
seonghobae Aug 18, 2026
6a728fd
test(audio): exercise module entrypoint with owned path
seonghobae Aug 18, 2026
2b83681
style(tests): restore Ruff formatting
seonghobae Aug 18, 2026
2909bc8
test(audio): reject malformed model stem output
seonghobae Aug 19, 2026
e5293a9
fix(audio): reject malformed model stem output
seonghobae Aug 19, 2026
2464ff9
test(audio): use explicit raw error pattern
seonghobae Aug 19, 2026
c35d55d
test(audio): cover model conversion failures
seonghobae Aug 19, 2026
15a9edb
test(audio): reproduce GPU tensor NumPy boundary
seonghobae Aug 19, 2026
c5519a4
fix(audio): move accelerator stems to CPU before NumPy
seonghobae Aug 19, 2026
389f057
fix(audio): preserve CPU tests while bridging accelerator stems
seonghobae Aug 19, 2026
2ea9e07
test(security): reproduce stem worker log disclosure
seonghobae Aug 20, 2026
b7dd015
test(security): cover parent stem failure logs
seonghobae Aug 20, 2026
cf8b62e
fix(security): redact analysis API tracebacks
seonghobae Aug 20, 2026
2f281be
fix(security): keep API privacy init lint-safe
seonghobae Aug 20, 2026
a8bbb77
test(security): scope analysis log redaction
seonghobae Aug 20, 2026
223dd78
fix(security): scope stem diagnostic redaction
seonghobae Aug 20, 2026
c2cc5bb
fix(audio): preflight source metadata before decode
seonghobae Aug 28, 2026
b4a3513
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 29, 2026
505a595
fix(chords): handle zero-element layouts
seonghobae Aug 29, 2026
ecc2279
test(audio): require actionable oversize rejection copy
seonghobae Sep 1, 2026
f88d3a4
fix(audio): name the next action for oversized sources
seonghobae Sep 1, 2026
3ac09a6
test(audio): preserve structured resource rejection contract
seonghobae Sep 1, 2026
336195a
fix(audio): preserve resource rejection provenance
seonghobae Sep 1, 2026
7862ad2
test(audio): fail closed on parser ValueError leakage
seonghobae Sep 1, 2026
1f33cde
fix(audio): contain metadata parser ValueError details
seonghobae Sep 1, 2026
2ca91e0
test(audio): require one canonical PCM decode port
seonghobae Sep 2, 2026
5cc67e1
test(audio): keep decode-port RED compatible with frozen policy
seonghobae Sep 2, 2026
4b3009c
fix(audio): own one bounded PCM decode port
seonghobae Sep 2, 2026
c2e6509
fix(audio): route MIR consumers through owned decode port
seonghobae Sep 2, 2026
3a76907
test(audio): follow the owned decode-port preflight seam
seonghobae Sep 2, 2026
c5cc94f
fix(audio): document decode-port regressions
seonghobae Sep 3, 2026
609de8d
Merge protected develop into Resource Admission owner
seonghobae Sep 3, 2026
9852265
test(audio): bind native intake diagnostics to resource owner
seonghobae Sep 5, 2026
dbeee9c
merge: adopt protected develop in resource admission lane
seonghobae Sep 5, 2026
804a286
test(audio): reject growth while materializing admitted source
seonghobae Sep 5, 2026
0beee45
fix(audio): bound admitted source materialization
seonghobae Sep 5, 2026
a2b1bd9
fix(audio): materialize selected source into project storage
seonghobae Sep 5, 2026
0ee15f0
docs(audio): trace app-owned source materialization
seonghobae Sep 5, 2026
323a7fa
fix(audio): export bounded source materialization port
seonghobae Sep 5, 2026
dcb3b25
docs(audio): record native port export repair
seonghobae Sep 5, 2026
131d6d7
test(audio): expose bounded-copy destination error
seonghobae Sep 5, 2026
ac4adfd
fix(audio): distinguish bounded-copy destination failure
seonghobae Sep 5, 2026
e2257d9
docs(audio): record bounded-copy diagnostics repair
seonghobae Sep 5, 2026
dc41379
test(audio): require native content identity receipt
seonghobae Sep 5, 2026
566cd1f
fix(audio): emit streaming content identity receipt
seonghobae Sep 5, 2026
f955661
docs(audio): trace native content identity receipt
seonghobae Sep 5, 2026
373824c
test(core): require reusable SHA-256 reader boundary
seonghobae Sep 5, 2026
d1ba406
fix(core): expose reusable SHA-256 reader boundary
seonghobae Sep 5, 2026
8a4f50c
docs(core): record reusable SHA-256 consolidation port
seonghobae Sep 5, 2026
fdfdd70
test(audio): require publication-bound source identity
seonghobae Sep 5, 2026
a1c85cb
fix(audio): verify published source identity
seonghobae Sep 5, 2026
20e7faa
fix(audio): expose publication receipt verifier
seonghobae Sep 5, 2026
505d214
docs(audio): trace publication receipt verification
seonghobae Sep 5, 2026
6a0692e
test(audio): bound publication verification to receipt size
seonghobae Sep 5, 2026
c65a9fd
fix(audio): stop publication verification at expected bytes
seonghobae Sep 5, 2026
92f436a
docs(audio): bound publication verification read evidence
seonghobae Sep 5, 2026
dedaab7
test(audio): reject invalid publication receipt lengths
seonghobae Sep 5, 2026
ed9fe7e
test(audio): require publication-bound materializer receipt
seonghobae Sep 6, 2026
bdf8f87
fix(audio): bind published source to native receipt
seonghobae Sep 6, 2026
539bd57
docs(audio): align publication identity traceability
seonghobae Sep 6, 2026
51734ce
test(audio): align native oversize policy expectation
seonghobae Sep 6, 2026
dd78dee
test(audio): keep zero-byte guard on decode port
seonghobae Sep 6, 2026
6ef0096
test(audio): patch canonical decode boundary
seonghobae Sep 6, 2026
e5726df
test(audio): preserve unrelated separation test wording
seonghobae Sep 6, 2026
46ca91a
docs(security): align local-audio publication authority
seonghobae Sep 6, 2026
45b1f72
test(audio): require no-clobber source publication
seonghobae Sep 6, 2026
eb972e9
fix(audio): publish local source without clobber race
seonghobae Sep 6, 2026
55b0da5
docs(audio): record no-clobber publication decision
seonghobae Sep 6, 2026
bad908c
test(audio): require path-free publication identity handoff
seonghobae Sep 6, 2026
87bdeea
fix(audio): expose path-free publication identity
seonghobae Sep 6, 2026
344a9a3
fix(audio): export publication identity handoff
seonghobae Sep 6, 2026
681675d
docs(audio): record path-free publication identity boundary
seonghobae Sep 6, 2026
645457e
test(audio): require native publication identity retention
seonghobae Sep 6, 2026
f89996b
test(audio): keep Tauri retention as next production slice
seonghobae Sep 6, 2026
cbfa967
test(audio): require native retention of publication identity
seonghobae Sep 6, 2026
d8c57ce
test(audio): keep native-retention RED off canonical head
seonghobae Sep 6, 2026
106ae75
test(audio): require native retention of publication identity
seonghobae Sep 6, 2026
e4e2ba7
fix(audio): retain verified publication identity natively
seonghobae Sep 6, 2026
9a13d2b
docs(audio): record native publication identity retention
seonghobae Sep 6, 2026
06092be
fix(ci): format audio decode regressions
seonghobae Sep 6, 2026
839f5a0
fix(ci): format audio metadata regression
seonghobae Sep 6, 2026
841e1c9
chore(test): normalize decode docstring wrap
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
- 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 native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work.
- Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed.
- Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget.
- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics.
- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not.
- Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary.
- Treat every zero-element NumPy layout as empty chord input, including shapes whose first dimension is non-zero, before feature extraction.

### Changed

- Consolidated Bandit, dependency audits, supplemental secret checks, and Trivy into one trusted-branch security backstop, delegated CodeQL to GitHub default setup, and removed duplicate local PR security and release-preflight runs.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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)'] }
Expand Down
325 changes: 325 additions & 0 deletions apps/desktop/core/src/audio_resource.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
use crate::content_sha256::StreamingSha256;
use std::io::{ErrorKind, Read, Write};

/// Maximum encoded local-audio file size accepted by the desktop bootstrap boundary.
pub const MAX_LOCAL_AUDIO_FILE_BYTES: u64 = 100 * 1024 * 1024;

const LOCAL_AUDIO_READ_ERROR: &str = "Could not read the selected audio file.";
const LOCAL_AUDIO_WRITE_ERROR: &str = "Could not prepare the local project workspace.";
const LOCAL_AUDIO_TOO_LARGE_ERROR: &str =
"Choose a shorter or smaller song file to start analysis.";

/// Immutable identity evidence for one successfully staged local-audio byte stream.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LocalAudioCopyReceipt {
/// Exact number of bytes written successfully to the staging writer.
pub file_size_bytes: u64,
/// SHA-256 of exactly the bytes written successfully, encoded as lowercase hexadecimal.
pub content_sha256: String,
}

/// Validate a native local-audio file length before storing bootstrap metadata.
///
/// The caller must obtain this length from the native filesystem descriptor or
/// metadata boundary rather than from renderer-controlled JSON. The function
/// intentionally returns only bounded product messages and never includes a
/// local path or payload content.
pub fn validate_local_audio_file_size(file_size_bytes: u64) -> Result<u64, String> {
if file_size_bytes == 0 {
return Err(LOCAL_AUDIO_READ_ERROR.to_string());
}
if file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES {
return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string());
}
Ok(file_size_bytes)
}

fn read_retrying_interrupted(reader: &mut impl Read, buffer: &mut [u8]) -> Result<usize, String> {
loop {
match reader.read(buffer) {
Ok(read) => return Ok(read),
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(_) => return Err(LOCAL_AUDIO_READ_ERROR.to_string()),
}
}
}

fn copy_bounded_local_audio_with_limit<R: Read, W: Write>(
mut reader: R,
writer: &mut W,
max_bytes: u64,
) -> Result<LocalAudioCopyReceipt, String> {
let mut copied = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
let mut content_digest = StreamingSha256::default();

loop {
if copied == max_bytes {
let mut overflow_probe = [0_u8; 1];
let read = read_retrying_interrupted(&mut reader, &mut overflow_probe)?;
if read == 0 {
break;
}
return Err(LOCAL_AUDIO_TOO_LARGE_ERROR.to_string());
}

let remaining = (max_bytes - copied).min(buffer.len() as u64) as usize;
let read = read_retrying_interrupted(&mut reader, &mut buffer[..remaining])?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?;
content_digest
.update(&buffer[..read])
.map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?;
copied += read as u64;
}

if copied == 0 {
return Err(LOCAL_AUDIO_READ_ERROR.to_string());
}
let content_sha256 = content_digest
.finalize_hex()
.map_err(|_| LOCAL_AUDIO_READ_ERROR.to_string())?;
Ok(LocalAudioCopyReceipt {
file_size_bytes: copied,
content_sha256,
})
}

/// Copy one admitted local-audio stream into a staging writer and return native content identity.
///
/// Security Notes: callers must pass an already-open, OS-authorized source
/// descriptor and a private app-owned staging writer. The helper writes no more
/// than the 100 MiB ceiling, hashes exactly the bytes whose writes succeeded,
/// and, after reaching the ceiling exactly, reads only one probe byte to detect
/// source growth. Source-read and destination-write failures use distinct
/// bounded product errors so storage failures are not misdiagnosed as bad media.
/// The caller must discard the staging artifact on error, synchronize it before
/// publication, and bind the returned receipt only to the artifact that was
/// actually published.
pub fn copy_bounded_local_audio_with_receipt<R: Read, W: Write>(
reader: R,
writer: &mut W,
) -> Result<LocalAudioCopyReceipt, String> {
copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES)
}

/// Re-read a published app-owned source and prove that it matches its staging receipt.
///
/// Security Notes: the caller must pass an already-open descriptor for the
/// synchronized, published `source.<extension>` object. This helper opens no
/// path and grants no filesystem authority. The staging receipt is native
/// evidence from the prior bounded copy, so its byte length becomes the tighter
/// publication-read ceiling: the verifier hashes at most that many bytes and
/// reads one additional probe byte to reject growth. It then requires both size
/// and digest to equal the staging receipt. Any invalid expected length, read,
/// growth, truncation, or content mismatch is reported as a bounded
/// project-workspace failure because the selected source already passed
/// admission before publication.
pub fn verify_local_audio_publication_receipt<R: Read>(
reader: R,
expected: &LocalAudioCopyReceipt,
) -> Result<LocalAudioCopyReceipt, String> {
if expected.file_size_bytes == 0 || expected.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES {
return Err(LOCAL_AUDIO_WRITE_ERROR.to_string());
}

let mut sink = std::io::sink();
let actual = copy_bounded_local_audio_with_limit(reader, &mut sink, expected.file_size_bytes)
.map_err(|_| LOCAL_AUDIO_WRITE_ERROR.to_string())?;
if actual != *expected {
return Err(LOCAL_AUDIO_WRITE_ERROR.to_string());
}
Ok(actual)
}

/// Copy one admitted local-audio stream into a staging writer and return its byte count.
///
/// This compatibility adapter preserves the existing desktop call boundary while
/// callers migrate to `copy_bounded_local_audio_with_receipt`. It uses the same
/// bounded copy and content-hash path and discards only the returned digest.
pub fn copy_bounded_local_audio<R: Read, W: Write>(reader: R, writer: &mut W) -> Result<u64, String> {
copy_bounded_local_audio_with_receipt(reader, writer).map(|receipt| receipt.file_size_bytes)
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Error};

struct FailingWriter;

impl Write for FailingWriter {
fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
Err(Error::new(ErrorKind::Other, "simulated destination failure"))
}

fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}

struct FailingReader;

impl Read for FailingReader {
fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
Err(Error::new(ErrorKind::Other, "simulated source failure"))
}
}

struct InterruptedThenReader {
bytes: Cursor<Vec<u8>>,
interrupted: bool,
}

impl Read for InterruptedThenReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if !self.interrupted {
self.interrupted = true;
return Err(Error::from(ErrorKind::Interrupted));
}
self.bytes.read(buffer)
}
}

struct CountingReader {
bytes: Cursor<Vec<u8>>,
bytes_read: usize,
}

impl Read for CountingReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let read = self.bytes.read(buffer)?;
self.bytes_read += read;
Ok(read)
}
}

#[test]
fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() {
let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]);
let mut staged = Vec::new();

let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4)
.expect_err("a source that grows beyond the admitted byte limit must fail closed");

assert_eq!(error, LOCAL_AUDIO_TOO_LARGE_ERROR);
assert_eq!(staged, vec![1_u8, 2, 3, 4]);
}

#[test]
fn bounded_copy_accepts_the_exact_limit_and_reports_content_identity() {
let input = Cursor::new(vec![1_u8, 2, 3, 4]);
let mut staged = Vec::new();

let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4)
.expect("the exact encoded-byte limit remains admissible");

assert_eq!(receipt.file_size_bytes, 4);
assert_eq!(
receipt.content_sha256,
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"
);
assert_eq!(staged, vec![1_u8, 2, 3, 4]);
}

#[test]
fn bounded_copy_reports_destination_failure_as_workspace_failure() {
let input = Cursor::new(vec![1_u8, 2, 3, 4]);
let mut staged = FailingWriter;

let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4)
.expect_err("a staging write failure must not be reported as a source read failure");

assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR);
}

#[test]
fn bounded_copy_keeps_source_failure_distinct_from_workspace_failure() {
let input = FailingReader;
let mut staged = Vec::new();

let error = copy_bounded_local_audio_with_limit(input, &mut staged, 4)
.expect_err("a source read failure must retain the media-read diagnosis");

assert_eq!(error, LOCAL_AUDIO_READ_ERROR);
assert!(staged.is_empty());
}

#[test]
fn bounded_copy_retries_interrupted_source_reads_without_changing_identity() {
let input = InterruptedThenReader {
bytes: Cursor::new(vec![1_u8, 2, 3, 4]),
interrupted: false,
};
let mut staged = Vec::new();

let receipt = copy_bounded_local_audio_with_limit(input, &mut staged, 4)
.expect("an interrupted source read should be retried");

assert_eq!(receipt.file_size_bytes, 4);
assert_eq!(
receipt.content_sha256,
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"
);
assert_eq!(staged, vec![1_u8, 2, 3, 4]);
}

#[test]
fn publication_verification_maps_read_failure_to_workspace_failure() {
let expected = LocalAudioCopyReceipt {
file_size_bytes: 4,
content_sha256:
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"
.to_string(),
};

let error = verify_local_audio_publication_receipt(FailingReader, &expected)
.expect_err("published artifact read failure must be a workspace failure");

assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR);
}

#[test]
fn publication_verification_stops_after_expected_size_plus_one_probe_byte() {
let expected = LocalAudioCopyReceipt {
file_size_bytes: 4,
content_sha256:
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"
.to_string(),
};
let mut published = CountingReader {
bytes: Cursor::new(vec![1_u8, 2, 3, 4, 5, 6, 7, 8]),
bytes_read: 0,
};

let error = verify_local_audio_publication_receipt(&mut published, &expected)
.expect_err("a grown published artifact must fail without scanning unrelated tail bytes");

assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR);
assert_eq!(published.bytes_read, 5);
}

#[test]
fn publication_verification_rejects_impossible_expected_lengths_without_reading() {
for file_size_bytes in [0, MAX_LOCAL_AUDIO_FILE_BYTES + 1] {
let expected = LocalAudioCopyReceipt {
file_size_bytes,
content_sha256: "00".repeat(32),
};
let mut published = CountingReader {
bytes: Cursor::new(vec![1_u8, 2, 3, 4]),
bytes_read: 0,
};

let error = verify_local_audio_publication_receipt(&mut published, &expected)
.expect_err("an impossible native receipt length must fail before reading");

assert_eq!(error, LOCAL_AUDIO_WRITE_ERROR);
assert_eq!(published.bytes_read, 0);
}
}
}
Loading
Loading