diff --git a/.github/workflows/project-persistence-windows.yml b/.github/workflows/project-persistence-windows.yml
new file mode 100644
index 000000000..4d451d3a6
--- /dev/null
+++ b/.github/workflows/project-persistence-windows.yml
@@ -0,0 +1,67 @@
+name: project-persistence-windows
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - main
+ paths:
+ - "apps/desktop/core/Cargo.toml"
+ - "apps/desktop/core/src/lib.rs"
+ - "apps/desktop/core/src/crate_root.rs"
+ - "apps/desktop/core/src/project_format.rs"
+ - "apps/desktop/core/tests/project_persistence*.rs"
+ - "apps/desktop/core/tests/project_format*.rs"
+ - "apps/desktop/core/testdata/project-*.json"
+ - "apps/desktop/src-tauri/Cargo.toml"
+ - "apps/desktop/src-tauri/Cargo.lock"
+ - "apps/desktop/src-tauri/src/main.rs"
+ - "apps/desktop/src-tauri/src/project_persistence.rs"
+ - "apps/desktop/src-tauri/tests/project_persistence*.rs"
+ - ".github/workflows/project-persistence-windows.yml"
+ push:
+ branches:
+ - develop
+ - main
+ paths:
+ - "apps/desktop/core/Cargo.toml"
+ - "apps/desktop/core/src/lib.rs"
+ - "apps/desktop/core/src/crate_root.rs"
+ - "apps/desktop/core/src/project_format.rs"
+ - "apps/desktop/core/tests/project_persistence*.rs"
+ - "apps/desktop/core/tests/project_format*.rs"
+ - "apps/desktop/core/testdata/project-*.json"
+ - "apps/desktop/src-tauri/Cargo.toml"
+ - "apps/desktop/src-tauri/Cargo.lock"
+ - "apps/desktop/src-tauri/src/main.rs"
+ - "apps/desktop/src-tauri/src/project_persistence.rs"
+ - "apps/desktop/src-tauri/tests/project_persistence*.rs"
+ - ".github/workflows/project-persistence-windows.yml"
+
+permissions:
+ contents: read
+
+env:
+ GIT_CONFIG_COUNT: "1"
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: develop
+
+jobs:
+ windows-recovery-cleanup:
+ name: test / project-persistence / windows
+ runs-on: windows-2025
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - name: Install Rust 1.97.1
+ run: rustup toolchain install 1.97.1 --profile minimal
+ - name: Prepare compile-only frontendDist fixture
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force apps/desktop/dist | Out-Null
+ Set-Content -Path apps/desktop/dist/index.html -Value '
BandScope test fixture' -NoNewline
+ - name: Run Windows recovery-cleanup regression
+ run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..751068872 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-03-11
+Last updated: 2026-09-06
## Brand source
@@ -111,8 +111,9 @@ Last updated: 2026-03-11
- Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals.
- Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas.
- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands.
+- Current Project Persistence writes a strict `projectFormatVersion: 3` envelope around the validated rehearsal song, closed Active Player preference, and optional path-free app-owned audio `sourceReference`; legacy raw-song, v1, and v2 inputs remain readable through ordered migration. Resource Admission materializes the admitted local source as the fixed app-owned `source.` artifact, verifies publication byte identity, and retains a path-free native identity. Project Persistence injects that identity into Save and re-admits the exact size and SHA-256 on restart; production analysis revalidates the retained identity and decodes a verified private byte snapshot. Source/derived/decision/handoff expansion, autosave/recovery UX, and fresh Active Player audible authority remain follow-up work under #962/#961 rather than parallel stores.
- Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener.
-- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase.
+- Local audio intake validates an OS-selected source in Rust, enforces the canonical resource policy, publishes a no-clobber app-owned `source.` copy under the minted project aggregate, verifies the published bytes, and exposes only bounded bootstrap/path-free identity evidence to downstream Project Persistence and analysis consumers.
- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace.
- Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy.
- Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34331fb86..3bca8e7d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
+- Evolve local project writes to `projectFormatVersion: 3`: retain deterministic legacy/v1/v2 migration, persist the closed Active Player source preference, and optionally store a path-free app-owned audio `sourceReference` with bounded byte evidence and canonical SHA-256 content identity for process-restart re-admission.
### Changed
@@ -15,7 +16,27 @@
### Fixed
+- Preserve a reopened v3 project's native source selector and stored playback-source preference across an Open Project → Save Project round trip, so resaving cannot silently drop `sourceReference` or reset a non-default stem intent to Full mix.
+- Enforce one canonical local-audio resource policy across native local-file/YouTube bootstrap intake, the desktop bridge, Python request preflight, temporal decoding, and stem separation so oversized, overlong, malformed, wrong-rate, or non-finite input fails before bootstrap storage or expensive analysis/model work.
+- Preflight source-container duration, sample rate, and channel count from the already-open audio handle before temporal, stem, or bass-transcription decoders resample, downmix, or truncate it; successful metadata probes rewind the handle and malformed probes fail closed.
+- Bound the admitted canonical decoded mono buffer to 317,520,000 bytes as well as the existing 39,690,000-sample ceiling, so decoder dtype expansion cannot stay within the sample count while exceeding the explicit in-memory audio budget.
+- Fail closed on malformed known YouTube duration metadata before `download=True`; Boolean, non-numeric, non-finite, zero, negative, and non-canonical numeric-subtype duration evidence can no longer authorize a media download through Python numeric coercion or subclass semantics.
+- Align YouTube download admission with that same 100 MiB encoded-byte ceiling: abort in-flight with yt-dlp `max_filesize` and a progress hook, reject announced oversize before `download=True`, delete owned `.part` / `.ytdl` / `-Frag*` siblings from that import directory on abort, reject a completed path that resolves outside the current import cache before post-download validation, cleanup, or success, and delete owned post-download artifacts that still exceed the policy. A 60 MiB import that the old 50 MB check rejected is now accepted; a file one byte over 100 MiB is not.
+- Bound native stored-score PDF reads to the 25 MiB product limit before heap allocation and revalidate PDF magic on the same opened descriptor, preventing an attached score that later grows from bypassing the local resource boundary.
+- Treat every zero-element NumPy layout as empty chord input, including shapes whose first dimension is non-zero, before feature extraction.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
+- Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation.
+- Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content.
+- Reject a symlinked/reparse-point save parent before staging so a selected project path cannot redirect new project publication into a different directory.
+- Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows opens reparse points without following them and compares native volume serial plus file-index identity across the acquisition boundary.
+- Refuse last-component symlink following during Linux/macOS project handle acquisition and make that acquisition non-blocking so a preflight-to-open path swap cannot redirect the loader or stall it on a special file.
+- Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path.
+- Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it.
+- Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched.
+- Keep renderer project admission passive and path-free: custom prototypes, enumeration/descriptor traps, accessors, runtime playback authorities, unknown fields, invalid app-owned source references, unsafe byte-size values, and missing/non-canonical SHA-256 source identity fail closed before persistence IPC.
+- Keep local Demucs loading offline and bounded by resolving a private snapshot copied from the verified cache descriptor through `LocalRepo`; reject missing, modified, empty, non-regular, over-128-MiB, or descriptor-size-racing checkpoint state before model deserialization so mutable cache replacement, post-preflight growth/shrink, and oversized local artifacts cannot alter or exhaust one analysis load.
+- Bound PyTorch 2.6+ weights-only checkpoint incompatibility at the admitted local-model boundary instead of leaking serialized class details or silently enabling legacy pickle loading; incompatible technical cache state now returns the existing local-model-unavailable diagnostic and remains a Distribution serialization/provenance decision.
+- Reject PyTorch runtime-environment widening at the local Demucs boundary: `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD` cannot turn the upstream implicit checkpoint load back into unrestricted pickle deserialization, and `TORCH_DEVICE_BACKEND_AUTOLOAD=1` cannot auto-import out-of-tree backend extensions before model admission.
## [0.1.3] - 2026-04-29
@@ -75,4 +96,4 @@
- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
-- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
+- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
\ No newline at end of file
diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml
index b01a537dc..44f482e73 100644
--- a/apps/desktop/core/Cargo.toml
+++ b/apps/desktop/core/Cargo.toml
@@ -7,7 +7,7 @@ publish = false
[lib]
name = "bandscope_desktop_core"
-path = "src/lib.rs"
+path = "src/root.rs"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] }
diff --git a/apps/desktop/core/src/audio_resource.rs b/apps/desktop/core/src/audio_resource.rs
new file mode 100644
index 000000000..a8383067b
--- /dev/null
+++ b/apps/desktop/core/src/audio_resource.rs
@@ -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 {
+ 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 {
+ loop {
+ match reader.read(buffer) {
+ Ok(read) => return Ok(read),
+ Err(error) if error.kind() == ErrorKind::Interrupted => continue,
+ Err(_) => return Err(LOCAL_AUDIO_READ_ERROR.to_string()),
+ }
+ }
+}
+
+fn copy_bounded_local_audio_with_limit(
+ mut reader: R,
+ writer: &mut W,
+ max_bytes: u64,
+) -> Result {
+ 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(
+ reader: R,
+ writer: &mut W,
+) -> Result {
+ copy_bounded_local_audio_with_limit(reader, writer, MAX_LOCAL_AUDIO_FILE_BYTES)
+}
+
+/// Re-read a published app-owned source and prove that it matches its staging receipt.
+///
+/// Security Notes: the caller must pass an already-open descriptor for the
+/// synchronized, published `source.` object. This helper opens no
+/// path and grants no filesystem authority. The staging receipt is native
+/// evidence from the prior bounded copy, so its byte length becomes the tighter
+/// publication-read ceiling: the verifier hashes at most that many bytes and
+/// reads one additional probe byte to reject growth. It then requires both size
+/// and digest to equal the staging receipt. Any invalid expected length, read,
+/// growth, truncation, or content mismatch is reported as a bounded
+/// project-workspace failure because the selected source already passed
+/// admission before publication.
+pub fn verify_local_audio_publication_receipt(
+ reader: R,
+ expected: &LocalAudioCopyReceipt,
+) -> Result {
+ if expected.file_size_bytes == 0 || expected.file_size_bytes > MAX_LOCAL_AUDIO_FILE_BYTES {
+ return Err(LOCAL_AUDIO_WRITE_ERROR.to_string());
+ }
+
+ let mut sink = std::io::sink();
+ let actual = copy_bounded_local_audio_with_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(reader: R, writer: &mut W) -> Result {
+ copy_bounded_local_audio_with_receipt(reader, writer).map(|receipt| receipt.file_size_bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{Cursor, Error};
+
+ struct FailingWriter;
+
+ impl Write for FailingWriter {
+ fn write(&mut self, _buffer: &[u8]) -> std::io::Result {
+ Err(Error::new(ErrorKind::Other, "simulated destination failure"))
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ Ok(())
+ }
+ }
+
+ struct FailingReader;
+
+ impl Read for FailingReader {
+ fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result {
+ Err(Error::new(ErrorKind::Other, "simulated source failure"))
+ }
+ }
+
+ struct InterruptedThenReader {
+ bytes: Cursor>,
+ interrupted: bool,
+ }
+
+ impl Read for InterruptedThenReader {
+ fn read(&mut self, buffer: &mut [u8]) -> std::io::Result {
+ if !self.interrupted {
+ self.interrupted = true;
+ return Err(Error::from(ErrorKind::Interrupted));
+ }
+ self.bytes.read(buffer)
+ }
+ }
+
+ struct CountingReader {
+ bytes: Cursor>,
+ bytes_read: usize,
+ }
+
+ impl Read for CountingReader {
+ fn read(&mut self, buffer: &mut [u8]) -> std::io::Result {
+ let read = self.bytes.read(buffer)?;
+ self.bytes_read += read;
+ Ok(read)
+ }
+ }
+
+ #[test]
+ fn bounded_copy_rejects_stream_growth_without_staging_bytes_past_the_limit() {
+ let input = Cursor::new(vec![1_u8, 2, 3, 4, 5]);
+ 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);
+ }
+ }
+}
diff --git a/apps/desktop/core/src/content_sha256.rs b/apps/desktop/core/src/content_sha256.rs
new file mode 100644
index 000000000..dbb109a49
--- /dev/null
+++ b/apps/desktop/core/src/content_sha256.rs
@@ -0,0 +1,293 @@
+//! Streaming SHA-256 for local content-identity receipts.
+//!
+//! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer
+//! tests below are correctness checks, not CAVP validation or a FIPS 140 claim.
+
+use std::io::{self, ErrorKind, Read};
+
+const BLOCK_BYTES: usize = 64;
+const DIGEST_BYTES: usize = 32;
+const INITIAL_STATE: [u32; 8] = [
+ 0x6a09_e667,
+ 0xbb67_ae85,
+ 0x3c6e_f372,
+ 0xa54f_f53a,
+ 0x510e_527f,
+ 0x9b05_688c,
+ 0x1f83_d9ab,
+ 0x5be0_cd19,
+];
+const ROUND_CONSTANTS: [u32; 64] = [
+ 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1,
+ 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3,
+ 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786,
+ 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da,
+ 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147,
+ 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13,
+ 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b,
+ 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070,
+ 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a,
+ 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208,
+ 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2,
+];
+
+#[derive(Clone)]
+pub(crate) struct StreamingSha256 {
+ words: [u32; 8],
+ buffer: [u8; BLOCK_BYTES],
+ buffer_len: usize,
+ message_len_bytes: u64,
+}
+
+impl Default for StreamingSha256 {
+ fn default() -> Self {
+ Self {
+ words: INITIAL_STATE,
+ buffer: [0; BLOCK_BYTES],
+ buffer_len: 0,
+ message_len_bytes: 0,
+ }
+ }
+}
+
+impl StreamingSha256 {
+ /// Add the next contiguous admitted byte slice to this digest state.
+ pub(crate) fn update(&mut self, mut bytes: &[u8]) -> Result<(), ()> {
+ self.message_len_bytes = self
+ .message_len_bytes
+ .checked_add(bytes.len() as u64)
+ .ok_or(())?;
+
+ if self.buffer_len != 0 {
+ let copied = (BLOCK_BYTES - self.buffer_len).min(bytes.len());
+ self.buffer[self.buffer_len..self.buffer_len + copied]
+ .copy_from_slice(&bytes[..copied]);
+ self.buffer_len += copied;
+ bytes = &bytes[copied..];
+ if self.buffer_len == BLOCK_BYTES {
+ let block = self.buffer;
+ self.compress(&block);
+ self.buffer_len = 0;
+ }
+ }
+
+ while bytes.len() >= BLOCK_BYTES {
+ let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES].try_into().map_err(|_| ())?;
+ self.compress(block);
+ bytes = &bytes[BLOCK_BYTES..];
+ }
+
+ if !bytes.is_empty() {
+ self.buffer[..bytes.len()].copy_from_slice(bytes);
+ self.buffer_len = bytes.len();
+ }
+ Ok(())
+ }
+
+ /// Finalize the digest as canonical lowercase hexadecimal.
+ pub(crate) fn finalize_hex(mut self) -> Result {
+ let message_len_bits = self.message_len_bytes.checked_mul(8).ok_or(())?;
+
+ self.buffer[self.buffer_len] = 0x80;
+ self.buffer_len += 1;
+ if self.buffer_len > 56 {
+ self.buffer[self.buffer_len..].fill(0);
+ let block = self.buffer;
+ self.compress(&block);
+ self.buffer = [0; BLOCK_BYTES];
+ self.buffer_len = 0;
+ }
+ self.buffer[self.buffer_len..56].fill(0);
+ self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes());
+ let block = self.buffer;
+ self.compress(&block);
+
+ let mut digest = [0_u8; DIGEST_BYTES];
+ for (index, word) in self.words.into_iter().enumerate() {
+ digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes());
+ }
+
+ let mut encoded = String::with_capacity(DIGEST_BYTES * 2);
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ for byte in digest {
+ encoded.push(HEX[(byte >> 4) as usize] as char);
+ encoded.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ Ok(encoded)
+ }
+
+ fn compress(&mut self, block: &[u8; BLOCK_BYTES]) {
+ let mut schedule = [0_u32; 64];
+ for (index, chunk) in block.chunks_exact(4).enumerate() {
+ schedule[index] = u32::from_be_bytes(
+ chunk
+ .try_into()
+ .expect("SHA-256 message word always contains four bytes"),
+ );
+ }
+ for index in 16..64 {
+ let small_sigma0 = schedule[index - 15].rotate_right(7)
+ ^ schedule[index - 15].rotate_right(18)
+ ^ (schedule[index - 15] >> 3);
+ let small_sigma1 = schedule[index - 2].rotate_right(17)
+ ^ schedule[index - 2].rotate_right(19)
+ ^ (schedule[index - 2] >> 10);
+ schedule[index] = schedule[index - 16]
+ .wrapping_add(small_sigma0)
+ .wrapping_add(schedule[index - 7])
+ .wrapping_add(small_sigma1);
+ }
+
+ let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.words;
+ for index in 0..64 {
+ let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
+ let choose = (e & f) ^ ((!e) & g);
+ let temporary1 = h
+ .wrapping_add(big_sigma1)
+ .wrapping_add(choose)
+ .wrapping_add(ROUND_CONSTANTS[index])
+ .wrapping_add(schedule[index]);
+ let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
+ let majority = (a & b) ^ (a & c) ^ (b & c);
+ let temporary2 = big_sigma0.wrapping_add(majority);
+
+ h = g;
+ g = f;
+ f = e;
+ e = d.wrapping_add(temporary1);
+ d = c;
+ c = b;
+ b = a;
+ a = temporary1.wrapping_add(temporary2);
+ }
+
+ self.words[0] = self.words[0].wrapping_add(a);
+ self.words[1] = self.words[1].wrapping_add(b);
+ self.words[2] = self.words[2].wrapping_add(c);
+ self.words[3] = self.words[3].wrapping_add(d);
+ self.words[4] = self.words[4].wrapping_add(e);
+ self.words[5] = self.words[5].wrapping_add(f);
+ self.words[6] = self.words[6].wrapping_add(g);
+ self.words[7] = self.words[7].wrapping_add(h);
+ }
+}
+
+/// Hash a caller-owned byte stream as canonical lowercase SHA-256.
+///
+/// Security Notes: this helper never opens a path, logs bytes, or grants filesystem
+/// authority. The caller must supply an already-authorized reader and decide how
+/// the resulting digest is bound to a concrete artifact. `Interrupted` reads are
+/// retried; other reader failures are returned unchanged. This is content identity,
+/// not an authenticity primitive or a FIPS module-validation claim.
+pub fn sha256_hex_reader(mut reader: impl Read) -> io::Result {
+ let mut digest = StreamingSha256::default();
+ let mut chunk = [0_u8; 64 * 1024];
+ loop {
+ match reader.read(&mut chunk) {
+ Ok(0) => break,
+ Ok(read_bytes) => digest
+ .update(&chunk[..read_bytes])
+ .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large"))?,
+ Err(error) if error.kind() == ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ digest
+ .finalize_hex()
+ .map_err(|_| io::Error::new(ErrorKind::InvalidData, "SHA-256 input too large"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{Cursor, Error};
+
+ fn digest_in_chunks(bytes: &[u8], chunk_size: usize) -> String {
+ let mut digest = StreamingSha256::default();
+ for chunk in bytes.chunks(chunk_size) {
+ digest.update(chunk).expect("test vector length must fit SHA-256");
+ }
+ digest
+ .finalize_hex()
+ .expect("test vector bit length must fit SHA-256")
+ }
+
+ struct InterruptedShortReader {
+ bytes: Vec,
+ cursor: usize,
+ interrupted: bool,
+ }
+
+ impl Read for InterruptedShortReader {
+ fn read(&mut self, output: &mut [u8]) -> io::Result {
+ if !self.interrupted {
+ self.interrupted = true;
+ return Err(Error::from(ErrorKind::Interrupted));
+ }
+ if self.cursor == self.bytes.len() {
+ return Ok(0);
+ }
+ let copied = 7.min(output.len()).min(self.bytes.len() - self.cursor);
+ output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]);
+ self.cursor += copied;
+ Ok(copied)
+ }
+ }
+
+ struct FailingReader;
+
+ impl Read for FailingReader {
+ fn read(&mut self, _output: &mut [u8]) -> io::Result {
+ Err(Error::new(ErrorKind::Other, "fixture read failure"))
+ }
+ }
+
+ #[test]
+ fn matches_sha256_known_answer_vectors() {
+ for (message, expected) in [
+ (
+ &b""[..],
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ ),
+ (
+ &b"abc"[..],
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ ),
+ (
+ &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..],
+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
+ ),
+ ] {
+ assert_eq!(digest_in_chunks(message, 7), expected);
+ }
+ }
+
+ #[test]
+ fn shared_reader_retries_interrupted_short_reads() {
+ let bytes = (0..131_111)
+ .map(|index| (index % 251) as u8)
+ .collect::>();
+ let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference hash should succeed");
+ let actual = sha256_hex_reader(InterruptedShortReader {
+ bytes,
+ cursor: 0,
+ interrupted: false,
+ })
+ .expect("interrupted short reads should be retried");
+ assert_eq!(actual, expected);
+ }
+
+ #[test]
+ fn shared_reader_propagates_non_interrupted_failure() {
+ let error = sha256_hex_reader(FailingReader).expect_err("reader failure must propagate");
+ assert_eq!(error.kind(), ErrorKind::Other);
+ }
+
+ #[test]
+ fn matches_the_million_a_vector() {
+ assert_eq!(
+ digest_in_chunks(&vec![b'a'; 1_000_000], 64 * 1024),
+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
+ );
+ }
+}
diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs
index 200726570..aaf2fc812 100644
--- a/apps/desktop/core/src/lib.rs
+++ b/apps/desktop/core/src/lib.rs
@@ -122,12 +122,149 @@ pub enum AnalysisCacheStatus {
pub struct RehearsalSongPayload {
id: String,
title: String,
+ #[serde(
+ default,
+ deserialize_with = "deserialize_project_tempo",
+ skip_serializing_if = "Option::is_none"
+ )]
+ tempo: Option,
sections: Vec,
export_summary: ExportSummaryPayload,
- #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ deserialize_with = "deserialize_present_optional",
+ skip_serializing_if = "Option::is_none"
+ )]
+ collaboration: Option,
+ #[serde(
+ default,
+ deserialize_with = "deserialize_present_optional",
+ skip_serializing_if = "Option::is_none"
+ )]
score_attachments: Option>,
}
+fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result