From f83a1baebc793658c4d1805be00f11238f09ceac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:35:29 +0900 Subject: [PATCH 001/166] 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 002/166] 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 003/166] 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 004/166] 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 005/166] 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 006/166] 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 007/166] 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 008/166] 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 009/166] 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 010/166] 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 c2c86b8b4f82cbdceabdc52516a33d8d2bd8614a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 19:05:38 +0900 Subject: [PATCH 011/166] chore(ci): refresh bounded PDF exact-head gates Re-emit the unchanged #864 tree so cancelled/never-materialized current-head workflow evidence is replaced by fresh exact-head runs. No production, test, dependency, workflow, or documentation content changes. From f8ba40d10458ed6b7b3b0e9d4d8f79ec866c50df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:07:12 +0900 Subject: [PATCH 012/166] test(score): require bounded private attachment publication --- .../tests/score_pdf_attachment_publication.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_attachment_publication.rs diff --git a/apps/desktop/core/tests/score_pdf_attachment_publication.rs b/apps/desktop/core/tests/score_pdf_attachment_publication.rs new file mode 100644 index 000000000..3f209c9fa --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_attachment_publication.rs @@ -0,0 +1,98 @@ +use bandscope_desktop_core::publish_score_pdf_attachment; +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_attachment_publication_writes_complete_pdf_without_leaking_stage() { + let root = unique_test_dir("score-attachment-publish"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + let expected = b"%PDF-1.7\nrehearsal score"; + std::fs::write(&source, expected).expect("score fixture should be written"); + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + + let bytes = publish_score_pdf_attachment(&source, &root, score_id) + .expect("validated score should publish"); + + assert_eq!(bytes, expected.len() as u64); + assert_eq!( + std::fs::read(root.join(format!("{score_id}.pdf"))) + .expect("published score should be readable"), + expected + ); + assert!(!root.join(format!(".score-{score_id}.stage")).exists()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn score_attachment_publication_never_clobbers_existing_score_id() { + let root = unique_test_dir("score-attachment-noclobber"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + std::fs::write(&source, b"%PDF-1.7\nnew bytes").expect("score fixture should be written"); + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + let destination = root.join(format!("{score_id}.pdf")); + std::fs::write(&destination, b"%PDF-1.7\nexisting bytes") + .expect("existing attachment should be written"); + + let error = publish_score_pdf_attachment(&source, &root, score_id) + .expect_err("publication must not replace an existing attachment"); + + assert_eq!(error, "Could not attach the score PDF."); + assert_eq!( + std::fs::read(&destination).expect("existing attachment should remain readable"), + b"%PDF-1.7\nexisting bytes" + ); + assert!(!root.join(format!(".score-{score_id}.stage")).exists()); + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn score_attachment_publication_is_private_under_permissive_umask() { + use std::os::unix::fs::MetadataExt; + use std::process::Command; + + if std::env::var_os("BANDSCOPE_SCORE_UMASK_CHILD").is_some() { + let root = PathBuf::from( + std::env::var_os("BANDSCOPE_SCORE_UMASK_ROOT") + .expect("child score root should be supplied"), + ); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + std::fs::write(&source, b"%PDF-1.7\nprivate score") + .expect("score fixture should be written"); + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + publish_score_pdf_attachment(&source, &root, score_id) + .expect("score publication should succeed under permissive umask"); + let mode = std::fs::metadata(root.join(format!("{score_id}.pdf"))) + .expect("published score metadata should be readable") + .mode() + & 0o777; + assert_eq!(mode, 0o600); + return; + } + + let root = unique_test_dir("score-attachment-umask"); + let test_binary = std::env::current_exe().expect("test binary should resolve"); + let status = Command::new("sh") + .arg("-c") + .arg("umask 000; exec \"$1\" --exact score_attachment_publication_is_private_under_permissive_umask --nocapture") + .arg("bandscope-score-umask") + .arg(&test_binary) + .env("BANDSCOPE_SCORE_UMASK_CHILD", "1") + .env("BANDSCOPE_SCORE_UMASK_ROOT", &root) + .status() + .expect("umask child should run"); + + assert!(status.success(), "umask child regression should pass"); + let _ = std::fs::remove_dir_all(root); +} From 6e8c3a6f4579cb31adaa6e6bf1a818a2468bf6a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:08:52 +0900 Subject: [PATCH 013/166] fix(score): add private bounded attachment publisher --- apps/desktop/core/src/score_storage.rs | 236 +++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 apps/desktop/core/src/score_storage.rs diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs new file mode 100644 index 000000000..786be0ba8 --- /dev/null +++ b/apps/desktop/core/src/score_storage.rs @@ -0,0 +1,236 @@ +use crate::{is_valid_score_id, MAX_SCORE_PDF_BYTES, PDF_MAGIC}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::Path, +}; + +const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; +const SCORE_TOO_LARGE_ERROR: &str = "Score PDF is too large (exceeds 25MB limit)."; +const SCORE_INVALID_PDF_ERROR: &str = "The selected file is not a valid PDF."; +const COPY_BUFFER_BYTES: usize = 64 * 1024; + +fn create_private_stage(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Keep the staging pathname stable while its handle is open. The + // buyer-visible destination inherits the app-owned scores directory + // ACL; this is deliberately not described as POSIX-equivalent 0600. + options.share_mode(0); + } + + options + .open(path) + .map_err(|_| SCORE_ATTACH_ERROR.to_string()) +} + +fn copy_bounded_pdf_stream( + reader: &mut impl Read, + writer: &mut impl Write, + expected_len: u64, +) -> Result { + if expected_len == 0 { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + if expected_len > MAX_SCORE_PDF_BYTES { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + let mut buffer = [0_u8; COPY_BUFFER_BYTES]; + let mut total = 0_u64; + let mut magic = [0_u8; PDF_MAGIC.len()]; + let mut magic_len = 0_usize; + + while total < expected_len { + let remaining = (expected_len - total) as usize; + let read_len = remaining.min(buffer.len()); + let count = reader + .read(&mut buffer[..read_len]) + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if count == 0 { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + if magic_len < magic.len() { + let take = (magic.len() - magic_len).min(count); + magic[magic_len..magic_len + take].copy_from_slice(&buffer[..take]); + magic_len += take; + } + + writer + .write_all(&buffer[..count]) + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + total += count as u64; + } + + let mut growth_probe = [0_u8; 1]; + if reader + .read(&mut growth_probe) + .map_err(|_| SCORE_ATTACH_ERROR.to_string())? + != 0 + { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + if magic_len != magic.len() || magic != PDF_MAGIC { + return Err(SCORE_INVALID_PDF_ERROR.to_string()); + } + + Ok(total) +} + +#[cfg(unix)] +fn remove_owned_stage(path: &Path, owned: &File) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + + let expected = owned + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !current.is_file() || expected.dev() != current.dev() || expected.ino() != current.ino() { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) +} + +#[cfg(not(unix))] +fn remove_owned_stage(path: &Path, _owned: &File) -> Result<(), String> { + let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !current.is_file() { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) +} + +/// Publish one validated score PDF into the app-owned score workspace. +/// +/// The source is reopened once and copied from that descriptor with a fixed +/// 25 MiB ceiling. The descriptor length is snapshotted before copy; early EOF +/// and an extra byte after the snapshot both fail closed, so truncation or +/// growth cannot silently change the accepted resource. The staging file is +/// created with `create_new`; Unix requests mode `0600` at first visibility, +/// while Windows deliberately inherits the app-owned parent ACL. Publication +/// uses a hard link so an existing `.pdf` is never replaced. +/// +/// Security Notes: errors never include the source path or PDF bytes. On Unix, +/// staging cleanup compares device/inode identity before unlinking so a foreign +/// replacement is not deleted. Windows keeps the staging pathname non-shareable +/// while the handle is open, but descriptor-bound cleanup after handle close is +/// still a separate acceptance item under #1239. +pub fn publish_score_pdf_attachment( + source: &Path, + scores_root: &Path, + score_id: &str, +) -> Result { + if !is_valid_score_id(score_id) { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + let mut source_file = File::open(source).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let source_metadata = source_file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !source_metadata.is_file() { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + let expected_len = source_metadata.len(); + if expected_len > MAX_SCORE_PDF_BYTES { + return Err(SCORE_TOO_LARGE_ERROR.to_string()); + } + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + let mut stage_file = create_private_stage(&stage)?; + + let copy_result = copy_bounded_pdf_stream(&mut source_file, &mut stage_file, expected_len) + .and_then(|written| { + let after = source_file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if after.len() != expected_len { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + stage_file + .sync_all() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + Ok(written) + }); + + let written = match copy_result { + Ok(written) => written, + Err(error) => { + let _ = remove_owned_stage(&stage, &stage_file); + return Err(error); + } + }; + + if fs::hard_link(&stage, &destination).is_err() { + let _ = remove_owned_stage(&stage, &stage_file); + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + let destination_metadata = fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !destination_metadata.is_file() || destination_metadata.len() != written { + let _ = fs::remove_file(&destination); + let _ = remove_owned_stage(&stage, &stage_file); + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + remove_owned_stage(&stage, &stage_file)?; + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn bounded_stream_rejects_growth_after_length_snapshot() { + let mut reader = Cursor::new(b"%PDF-extra".to_vec()); + let mut output = Vec::new(); + + let error = copy_bounded_pdf_stream(&mut reader, &mut output, PDF_MAGIC.len() as u64) + .expect_err("growth after the descriptor snapshot must fail closed"); + + assert_eq!(error, SCORE_ATTACH_ERROR); + } + + #[test] + fn bounded_stream_rejects_truncation_after_length_snapshot() { + let mut reader = Cursor::new(PDF_MAGIC.to_vec()); + let mut output = Vec::new(); + + let error = copy_bounded_pdf_stream( + &mut reader, + &mut output, + (PDF_MAGIC.len() + 1) as u64, + ) + .expect_err("truncation after the descriptor snapshot must fail closed"); + + assert_eq!(error, SCORE_ATTACH_ERROR); + } + + #[test] + fn bounded_stream_rejects_wrong_magic_without_payload_echo() { + let bytes = b"PK\x03\x04-not-pdf"; + let mut reader = Cursor::new(bytes.to_vec()); + let mut output = Vec::new(); + + let error = copy_bounded_pdf_stream(&mut reader, &mut output, bytes.len() as u64) + .expect_err("wrong magic must fail closed"); + + assert_eq!(error, SCORE_INVALID_PDF_ERROR); + assert!(!error.contains("PK")); + } +} From d40f6e124a360fe7011b02797d30101c8143bc9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:10:15 +0900 Subject: [PATCH 014/166] fix(score): export attachment storage boundary --- apps/desktop/core/src/root.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 6dd1f4fc5..61831b091 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 -//! the same crate-root API. +//! compatibility module while bounded score-file I/O is 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 score_pdf; +mod score_storage; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; +pub use score_storage::publish_score_pdf_attachment; From 6eaa94f47430b6b48a20767cbf94232913df4415 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:15:06 +0900 Subject: [PATCH 015/166] fix(score): wire bounded private attachment publication --- apps/desktop/src-tauri/src/main.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 94b6c1a61..c37d02c38 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -784,11 +784,11 @@ fn scores_root_for_project( Ok(root) } -/// Security Notes: the file path comes exclusively from the OS file dialog -/// (never from JS), is validated (magic bytes, size, extension, no symlink), -/// and is copied into the app-owned scores directory. The stored copy is named -/// by a locally minted UUID v4, so no untrusted external path is ever -/// referenced again after this command returns. +/// Security Notes: the selected path comes only from the OS file dialog and is +/// admitted as a bounded, non-symlink PDF before publication. Score Storage +/// reopens that source once, copies only the descriptor-length snapshot into a +/// private staging file, rejects growth/truncation, and publishes without +/// replacing an existing score id. No local path or PDF bytes cross IPC. #[tauri::command] fn attach_score_pdf( project_id: String, @@ -808,13 +808,11 @@ fn attach_score_pdf( .add_filter("PDF Score", &["pdf"]) .pick_file() .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; - let (source, file_name, file_size_bytes) = validate_score_pdf_source(&path)?; + let (source, file_name, _validated_file_size_bytes) = validate_score_pdf_source(&path)?; let scores_root = scores_root_for_project(&app, &project_id)?; let score_id = uuid::Uuid::new_v4().to_string(); - let destination = scores_root.join(format!("{score_id}.pdf")); - std::fs::copy(&source, &destination) - .map_err(|_| "Could not copy the PDF into the project workspace.".to_string())?; + let file_size_bytes = publish_score_pdf_attachment(&source, &scores_root, &score_id)?; Ok(ScoreAttachmentPayload { score_id, From ea00f0c3a554cb1b8a9e9fa3344233ba6b69fe9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:15:17 +0900 Subject: [PATCH 016/166] test(score): pin Tauri attachment storage wiring --- .../core/tests/score_pdf_attachment_wiring.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_attachment_wiring.rs diff --git a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs new file mode 100644 index 000000000..6417c85f0 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs @@ -0,0 +1,16 @@ +const TAURI_MAIN: &str = include_str!("../../src-tauri/src/main.rs"); + +#[test] +fn tauri_attachment_command_uses_score_storage_publication_boundary() { + let attach_start = TAURI_MAIN + .find("fn attach_score_pdf(") + .expect("attach_score_pdf command should exist"); + let read_start = TAURI_MAIN[attach_start..] + .find("fn read_score_pdf(") + .map(|offset| attach_start + offset) + .expect("read_score_pdf command should follow attachment command"); + let attach_source = &TAURI_MAIN[attach_start..read_start]; + + assert!(attach_source.contains("publish_score_pdf_attachment(")); + assert!(!attach_source.contains("std::fs::copy(")); +} From 737526d5339592a1e3dd38c287f870402867b194 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:15:37 +0900 Subject: [PATCH 017/166] docs(score): trace private attachment publication boundary --- .../score-attachment-publication.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/traceability/score-attachment-publication.md diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md new file mode 100644 index 000000000..58fd85ff3 --- /dev/null +++ b/docs/traceability/score-attachment-publication.md @@ -0,0 +1,46 @@ +# Score attachment publication + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment + +## Problem + +The desktop command admitted a selected PDF and then copied it into `/scores` with `std::fs::copy`. That left the buyer-visible write contract implicit: first-visible Unix mode depended on copy/platform behavior, the source could change after path validation, an existing destination had no explicit no-clobber invariant, and partial-copy cleanup was not owned by a Score Storage boundary. + +The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This slice owns only score attachment publication and its lifecycle semantics. + +## Decision + +`publish_score_pdf_attachment` is the native Score Storage publication boundary. + +- Reopen the already-admitted source once and bind copy to that descriptor. +- Snapshot descriptor length and reject `0` or `> 25 MiB` before copying. +- Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. +- Revalidate `%PDF-` on the bytes that are actually copied. +- Stage with `create_new`. Unix requests `0600` at creation time instead of creating broad permissions and tightening them later. +- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`; the staging handle denies sharing while open. +- Publish with a hard link from the synchronized stage to `.pdf`, so an existing score id is not overwritten. +- On Unix, cleanup compares device/inode identity before unlinking a stage. A foreign replacement is never deleted merely because it occupies the expected pathname. +- Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. + +## Alternatives rejected + +`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. + +## Security Notes + +**Untrusted input.** The selected local PDF and any pre-existing pathname in the score workspace are untrusted. The file-dialog path itself is not sent by the WebView. + +**Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. + +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. + +**Privacy.** New Unix score bytes request `0600` at first visibility. Windows uses native ACL inheritance and does not claim POSIX permission equivalence. + +**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, and Tauri call-site wiring. Windows/macOS hosted exact-head results remain required before this PR can advance. + +## Remaining risk / claim boundary + +This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, and Windows identity-bound cleanup after the staging handle closes remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. + +The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. From 9584ab3b822fa7a6196159fdf00fab590694e7c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:16:54 +0900 Subject: [PATCH 018/166] ci(score): add exact-head native storage regressions --- .github/workflows/score-storage-native.yml | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/score-storage-native.yml diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml new file mode 100644 index 000000000..4ab918289 --- /dev/null +++ b/.github/workflows/score-storage-native.yml @@ -0,0 +1,63 @@ +name: score-storage-native + +on: + pull_request: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_storage.rs" + - "apps/desktop/core/tests/score_pdf_*.rs" + - "apps/desktop/src-tauri/src/main.rs" + - "docs/traceability/score-attachment-publication.md" + - ".github/workflows/score-storage-native.yml" + push: + branches: + - develop + - main + paths: + - "apps/desktop/core/Cargo.toml" + - "apps/desktop/core/src/root.rs" + - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_storage.rs" + - "apps/desktop/core/tests/score_pdf_*.rs" + - "apps/desktop/src-tauri/src/main.rs" + - "docs/traceability/score-attachment-publication.md" + - ".github/workflows/score-storage-native.yml" + +permissions: + contents: read + +jobs: + native-score-storage: + name: test / score-storage / ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Install Rust 1.97.1 + run: rustup toolchain install 1.97.1 --profile minimal + - name: Run score storage regressions + run: >- + cargo +1.97.1 test + --manifest-path apps/desktop/core/Cargo.toml + --lib + --test score_pdf_attachment_publication + --test score_pdf_attachment_wiring From bcb6c2e647a311512fd1a2d43e5a21ab6331f115 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:18:43 +0900 Subject: [PATCH 019/166] ci(score): scope native lane to owned score tests --- .github/workflows/score-storage-native.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 4ab918289..a1f30f459 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -54,10 +54,14 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install Rust 1.97.1 run: rustup toolchain install 1.97.1 --profile minimal - - name: Run score storage regressions + - name: Run owned Score Storage unit tests + run: >- + cargo +1.97.1 test + --manifest-path apps/desktop/core/Cargo.toml + --lib score_storage::tests:: + - name: Run Score Storage publication and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml - --lib --test score_pdf_attachment_publication --test score_pdf_attachment_wiring From 69463eba53b61ba0505e4121317e3d726bfa4096 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:19:28 +0900 Subject: [PATCH 020/166] test(score): use valid UUIDv4 attachment ids --- .../tests/score_pdf_attachment_publication.rs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_attachment_publication.rs b/apps/desktop/core/tests/score_pdf_attachment_publication.rs index 3f209c9fa..e8d92974a 100644 --- a/apps/desktop/core/tests/score_pdf_attachment_publication.rs +++ b/apps/desktop/core/tests/score_pdf_attachment_publication.rs @@ -2,6 +2,8 @@ use bandscope_desktop_core::publish_score_pdf_attachment; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -17,18 +19,17 @@ fn score_attachment_publication_writes_complete_pdf_without_leaking_stage() { let source = root.join("selected.pdf"); let expected = b"%PDF-1.7\nrehearsal score"; std::fs::write(&source, expected).expect("score fixture should be written"); - let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; - let bytes = publish_score_pdf_attachment(&source, &root, score_id) + let bytes = publish_score_pdf_attachment(&source, &root, SCORE_ID) .expect("validated score should publish"); assert_eq!(bytes, expected.len() as u64); assert_eq!( - std::fs::read(root.join(format!("{score_id}.pdf"))) + std::fs::read(root.join(format!("{SCORE_ID}.pdf"))) .expect("published score should be readable"), expected ); - assert!(!root.join(format!(".score-{score_id}.stage")).exists()); + assert!(!root.join(format!(".score-{SCORE_ID}.stage")).exists()); let _ = std::fs::remove_dir_all(root); } @@ -38,12 +39,11 @@ fn score_attachment_publication_never_clobbers_existing_score_id() { std::fs::create_dir_all(&root).expect("score root should be created"); let source = root.join("selected.pdf"); std::fs::write(&source, b"%PDF-1.7\nnew bytes").expect("score fixture should be written"); - let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; - let destination = root.join(format!("{score_id}.pdf")); + let destination = root.join(format!("{SCORE_ID}.pdf")); std::fs::write(&destination, b"%PDF-1.7\nexisting bytes") .expect("existing attachment should be written"); - let error = publish_score_pdf_attachment(&source, &root, score_id) + let error = publish_score_pdf_attachment(&source, &root, SCORE_ID) .expect_err("publication must not replace an existing attachment"); assert_eq!(error, "Could not attach the score PDF."); @@ -51,7 +51,7 @@ fn score_attachment_publication_never_clobbers_existing_score_id() { std::fs::read(&destination).expect("existing attachment should remain readable"), b"%PDF-1.7\nexisting bytes" ); - assert!(!root.join(format!(".score-{score_id}.stage")).exists()); + assert!(!root.join(format!(".score-{SCORE_ID}.stage")).exists()); let _ = std::fs::remove_dir_all(root); } @@ -70,10 +70,9 @@ fn score_attachment_publication_is_private_under_permissive_umask() { let source = root.join("selected.pdf"); std::fs::write(&source, b"%PDF-1.7\nprivate score") .expect("score fixture should be written"); - let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; - publish_score_pdf_attachment(&source, &root, score_id) + publish_score_pdf_attachment(&source, &root, SCORE_ID) .expect("score publication should succeed under permissive umask"); - let mode = std::fs::metadata(root.join(format!("{score_id}.pdf"))) + let mode = std::fs::metadata(root.join(format!("{SCORE_ID}.pdf"))) .expect("published score metadata should be readable") .mode() & 0o777; From 82496a01880d2175d56ebbf5713420a735be8fb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:22:50 +0900 Subject: [PATCH 021/166] fix(score): close Windows stage handle before publication --- apps/desktop/core/src/score_storage.rs | 68 ++++++++++++++++++++------ 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 786be0ba8..6df56218c 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -23,9 +23,10 @@ fn create_private_stage(path: &Path) -> Result { #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; - // Keep the staging pathname stable while its handle is open. The - // buyer-visible destination inherits the app-owned scores directory - // ACL; this is deliberately not described as POSIX-equivalent 0600. + // Deny pathname sharing while bytes are being written. Windows also + // denies the later hard-link/unlink operations while this handle is + // open, so publication deliberately starts only after the synchronized + // stage handle is closed. options.share_mode(0); } @@ -90,21 +91,47 @@ fn copy_bounded_pdf_stream( } #[cfg(unix)] -fn remove_owned_stage(path: &Path, owned: &File) -> Result<(), String> { +#[derive(Clone, Copy)] +struct StageIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +fn stage_identity(file: &File) -> Result { use std::os::unix::fs::MetadataExt; - let expected = owned + let metadata = file .metadata() .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + Ok(StageIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(not(unix))] +#[derive(Clone, Copy)] +struct StageIdentity; + +#[cfg(not(unix))] +fn stage_identity(_file: &File) -> Result { + Ok(StageIdentity) +} + +#[cfg(unix)] +fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; - if !current.is_file() || expected.dev() != current.dev() || expected.ino() != current.ino() { + if !current.is_file() || expected.device != current.dev() || expected.inode != current.ino() { return Err(SCORE_ATTACH_ERROR.to_string()); } fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) } #[cfg(not(unix))] -fn remove_owned_stage(path: &Path, _owned: &File) -> Result<(), String> { +fn remove_owned_stage(path: &Path, _expected: StageIdentity) -> Result<(), String> { let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !current.is_file() { return Err(SCORE_ATTACH_ERROR.to_string()); @@ -123,10 +150,11 @@ fn remove_owned_stage(path: &Path, _owned: &File) -> Result<(), String> { /// uses a hard link so an existing `.pdf` is never replaced. /// /// Security Notes: errors never include the source path or PDF bytes. On Unix, -/// staging cleanup compares device/inode identity before unlinking so a foreign -/// replacement is not deleted. Windows keeps the staging pathname non-shareable -/// while the handle is open, but descriptor-bound cleanup after handle close is -/// still a separate acceptance item under #1239. +/// staging cleanup compares device/inode identity captured from the open stage +/// before unlinking so a foreign replacement is not deleted. Windows denies +/// stage pathname sharing during write and closes the synchronized handle before +/// publication; identity-bound cleanup after handle close remains a separate +/// acceptance item under #1239. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, @@ -151,6 +179,7 @@ pub fn publish_score_pdf_attachment( let stage = scores_root.join(format!(".score-{score_id}.stage")); let destination = scores_root.join(format!("{score_id}.pdf")); let mut stage_file = create_private_stage(&stage)?; + let expected_stage = stage_identity(&stage_file)?; let copy_result = copy_bounded_pdf_stream(&mut source_file, &mut stage_file, expected_len) .and_then(|written| { @@ -169,24 +198,31 @@ pub fn publish_score_pdf_attachment( let written = match copy_result { Ok(written) => written, Err(error) => { - let _ = remove_owned_stage(&stage, &stage_file); + drop(stage_file); + let _ = remove_owned_stage(&stage, expected_stage); return Err(error); } }; + // Windows cannot create a hard link to, or unlink, a path whose handle was + // opened with share_mode(0). Closing only after sync preserves exclusive + // write ownership while making the completed stage publishable. + drop(stage_file); + if fs::hard_link(&stage, &destination).is_err() { - let _ = remove_owned_stage(&stage, &stage_file); + let _ = remove_owned_stage(&stage, expected_stage); return Err(SCORE_ATTACH_ERROR.to_string()); } let destination_metadata = fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !destination_metadata.is_file() || destination_metadata.len() != written { - let _ = fs::remove_file(&destination); - let _ = remove_owned_stage(&stage, &stage_file); + // Do not unlink `destination` here: after publication a pathname swap + // could make it foreign. Preserve unexpected evidence and fail closed. + let _ = remove_owned_stage(&stage, expected_stage); return Err(SCORE_ATTACH_ERROR.to_string()); } - remove_owned_stage(&stage, &stage_file)?; + remove_owned_stage(&stage, expected_stage)?; Ok(written) } From b54151e137f83bda9291aef81f288b59a5e8f905 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:23:38 +0900 Subject: [PATCH 022/166] docs(score): record Windows publication handle boundary --- .../traceability/score-attachment-publication.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 58fd85ff3..3599997ec 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -18,14 +18,20 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. - Revalidate `%PDF-` on the bytes that are actually copied. - Stage with `create_new`. Unix requests `0600` at creation time instead of creating broad permissions and tightening them later. -- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`; the staging handle denies sharing while open. +- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage unlink. This preserves exclusive write ownership without asking Windows to rename/link/delete a pathname whose handle forbids those operations. - Publish with a hard link from the synchronized stage to `.pdf`, so an existing score id is not overwritten. -- On Unix, cleanup compares device/inode identity before unlinking a stage. A foreign replacement is never deleted merely because it occupies the expected pathname. +- On Unix, stage identity is captured from the open file and cleanup compares device/inode before unlinking. A foreign replacement is never deleted merely because it occupies the expected pathname. - Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. +## Hosted finding and repair + +Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a platform contract from test/workflow noise. macOS passed the owned Score Storage unit and publication regressions. Windows passed all three owned unit tests but failed both publication regressions: the success path returned `Could not attach the score PDF.`, and the duplicate-destination case left the stage pathname behind. The common cause was the Windows stage handle opened with `share_mode(0)` remaining live while `hard_link` or `remove_file` needed pathname mutation. + +The repair keeps `share_mode(0)` during the untrusted write, captures the stage identity while the handle is live, calls `sync_all`, and drops the handle before publication or cleanup. No sharing flag was loosened merely to make the test pass. Unix retains device/inode-bound cleanup; Windows identity-bound cleanup after handle close remains a separate acceptance item. + ## Alternatives rejected -`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. +`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was also rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. ## Security Notes @@ -33,7 +39,7 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works **Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. **Privacy.** New Unix score bytes request `0600` at first visibility. Windows uses native ACL inheritance and does not claim POSIX permission equivalence. @@ -41,6 +47,6 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works ## Remaining risk / claim boundary -This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, and Windows identity-bound cleanup after the staging handle closes remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. +This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, actual Windows ACL/inheritance acceptance, and Windows identity-bound cleanup after the staging handle closes remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. From a2e2d55019e2378d4a5f4af0f5d2fc21d01e4248 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:25:44 +0900 Subject: [PATCH 023/166] docs(score): add authoritative storage references --- .../score-attachment-publication.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 3599997ec..0c49d2c8e 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -17,8 +17,8 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works - Snapshot descriptor length and reject `0` or `> 25 MiB` before copying. - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. - Revalidate `%PDF-` on the bytes that are actually copied. -- Stage with `create_new`. Unix requests `0600` at creation time instead of creating broad permissions and tightening them later. -- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage unlink. This preserves exclusive write ownership without asking Windows to rename/link/delete a pathname whose handle forbids those operations. +- Stage with `create_new`. Rust documents `create_new` as atomic with respect to an existing target; Unix `OpenOptionsExt::mode` supplies the creation mode before `umask` is applied. Unix therefore requests `0600` instead of creating broad permissions and tightening them later. +- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage unlink. Microsoft documents a zero CreateFile share mode as preventing conflicting read/write/delete opens until the handle closes. - Publish with a hard link from the synchronized stage to `.pdf`, so an existing score id is not overwritten. - On Unix, stage identity is captured from the open file and cleanup compares device/inode before unlinking. A foreign replacement is never deleted merely because it occupies the expected pathname. - Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. @@ -29,6 +29,8 @@ Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` sep The repair keeps `share_mode(0)` during the untrusted write, captures the stage identity while the handle is live, calls `sync_all`, and drops the handle before publication or cleanup. No sharing flag was loosened merely to make the test pass. Unix retains device/inode-bound cleanup; Windows identity-bound cleanup after handle close remains a separate acceptance item. +Microsoft's `FILE_ID_INFO` contract gives the eventual Windows identity model: volume serial number plus the 128-bit file identifier identifies a file on one computer. Rust's current Windows `MetadataExt::file_index`/`volume_serial_number` surface is still documented as nightly-only, so this PR does not pretend that stable Rust 1.97.1 already supplies an equivalent identity primitive. A later Windows cleanup repair must use an audited Win32 binding or another stable owner-approved mechanism and carry its own native regression. + ## Alternatives rejected `std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was also rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. @@ -50,3 +52,15 @@ The repair keeps `share_mode(0)` during the untrusted write, captures the stage This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, actual Windows ACL/inheritance acceptance, and Windows identity-bound cleanup after the staging handle closes remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. + +## References + +Microsoft. (2024, February 22). *FILE_ID_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_id_info + +Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea + +The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html + +The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html + +The Rust Project Developers. (2026). *MetadataExt — std::os::windows::fs*. Rust documentation. https://doc.rust-lang.org/beta/std/os/windows/fs/trait.MetadataExt.html From a7373ca153e8740923ad13098953a61a7c263cc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:32:50 +0900 Subject: [PATCH 024/166] test(score): preserve replaced stage during Windows cleanup --- apps/desktop/core/src/score_storage.rs | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 6df56218c..813437019 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -269,4 +269,33 @@ mod tests { assert_eq!(error, SCORE_INVALID_PDF_ERROR); assert!(!error.contains("PK")); } + + #[cfg(windows)] + #[test] + fn windows_cleanup_preserves_replaced_stage_path() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("bandscope-score-stage-identity-{suffix}")); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join("stage.pdf"); + let stage_file = create_private_stage(&stage).expect("owned stage should be created"); + let expected = stage_identity(&stage_file).expect("owned stage identity should be captured"); + drop(stage_file); + + fs::remove_file(&stage).expect("owned stage should be removable for replacement fixture"); + fs::write(&stage, b"foreign replacement").expect("foreign replacement should be written"); + + let error = remove_owned_stage(&stage, expected) + .expect_err("cleanup must reject a replacement that is not the captured stage file"); + assert_eq!(error, SCORE_ATTACH_ERROR); + assert_eq!( + fs::read(&stage).expect("foreign replacement must survive failed cleanup"), + b"foreign replacement" + ); + let _ = fs::remove_dir_all(root); + } } From 49976461c7158869d1f10a6a43b46e0ee522352f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:35:47 +0900 Subject: [PATCH 025/166] fix(score): bind Windows stage cleanup to file identity --- apps/desktop/core/src/score_storage.rs | 101 ++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 813437019..96efad709 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -110,11 +110,73 @@ fn stage_identity(file: &File) -> Result { }) } -#[cfg(not(unix))] +#[cfg(windows)] +#[repr(C)] +struct WindowsFileId128 { + identifier: [u8; 16], +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileIdInfo { + volume_serial_number: u64, + file_id: WindowsFileId128, +} + +#[cfg(windows)] +const FILE_ID_INFO_CLASS: i32 = 0x12; + +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + fn GetFileInformationByHandleEx( + file: *mut std::ffi::c_void, + file_information_class: i32, + file_information: *mut std::ffi::c_void, + buffer_size: u32, + ) -> i32; +} + +#[cfg(windows)] +#[derive(Clone, Copy, PartialEq, Eq)] +struct StageIdentity { + volume_serial_number: u64, + file_id: [u8; 16], +} + +#[cfg(windows)] +fn stage_identity(file: &File) -> Result { + use std::{ + mem::{size_of, MaybeUninit}, + os::windows::io::AsRawHandle, + }; + + let mut info = MaybeUninit::::zeroed(); + // FILE_ID_INFO is the Windows identity contract here: volume serial plus + // the 128-bit file id avoids treating a pathname as cleanup authority. + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FILE_ID_INFO_CLASS, + info.as_mut_ptr().cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + let info = unsafe { info.assume_init() }; + Ok(StageIdentity { + volume_serial_number: info.volume_serial_number, + file_id: info.file_id.identifier, + }) +} + +#[cfg(all(not(unix), not(windows)))] #[derive(Clone, Copy)] struct StageIdentity; -#[cfg(not(unix))] +#[cfg(all(not(unix), not(windows)))] fn stage_identity(_file: &File) -> Result { Ok(StageIdentity) } @@ -130,7 +192,22 @@ fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) } -#[cfg(not(unix))] +#[cfg(windows)] +fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { + let current_metadata = + fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !current_metadata.is_file() { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + let current_file = File::open(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if stage_identity(¤t_file)? != expected { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + drop(current_file); + fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) +} + +#[cfg(all(not(unix), not(windows)))] fn remove_owned_stage(path: &Path, _expected: StageIdentity) -> Result<(), String> { let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !current.is_file() { @@ -149,12 +226,13 @@ fn remove_owned_stage(path: &Path, _expected: StageIdentity) -> Result<(), Strin /// while Windows deliberately inherits the app-owned parent ACL. Publication /// uses a hard link so an existing `.pdf` is never replaced. /// -/// Security Notes: errors never include the source path or PDF bytes. On Unix, -/// staging cleanup compares device/inode identity captured from the open stage -/// before unlinking so a foreign replacement is not deleted. Windows denies -/// stage pathname sharing during write and closes the synchronized handle before -/// publication; identity-bound cleanup after handle close remains a separate -/// acceptance item under #1239. +/// Security Notes: errors never include the source path or PDF bytes. Unix +/// cleanup compares device/inode identity captured from the open stage. Windows +/// captures the volume serial plus 128-bit `FILE_ID_INFO` from the original +/// handle and requires the same identity after reopening the stage pathname, so +/// a foreign replacement is not accepted merely because it is a regular file. +/// The final identity-check-to-unlink interval is still pathname based and is +/// not claimed as descriptor-relative deletion. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, @@ -214,7 +292,8 @@ pub fn publish_score_pdf_attachment( return Err(SCORE_ATTACH_ERROR.to_string()); } - let destination_metadata = fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let destination_metadata = + fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !destination_metadata.is_file() || destination_metadata.len() != written { // Do not unlink `destination` here: after publication a pathname swap // could make it foreign. Preserve unexpected evidence and fail closed. @@ -252,7 +331,7 @@ mod tests { &mut output, (PDF_MAGIC.len() + 1) as u64, ) - .expect_err("truncation after the descriptor snapshot must fail closed"); + .expect_err("truncation after the metadata snapshot must fail closed"); assert_eq!(error, SCORE_ATTACH_ERROR); } From 880ec6bf645c46ff861acebf2ac434c3bfae5a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 00:36:23 +0900 Subject: [PATCH 026/166] docs(score): trace Windows stage identity repair --- .../score-attachment-publication.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 0c49d2c8e..6b93de97f 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -18,22 +18,27 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. - Revalidate `%PDF-` on the bytes that are actually copied. - Stage with `create_new`. Rust documents `create_new` as atomic with respect to an existing target; Unix `OpenOptionsExt::mode` supplies the creation mode before `umask` is applied. Unix therefore requests `0600` instead of creating broad permissions and tightening them later. -- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage unlink. Microsoft documents a zero CreateFile share mode as preventing conflicting read/write/delete opens until the handle closes. +- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage cleanup. - Publish with a hard link from the synchronized stage to `.pdf`, so an existing score id is not overwritten. -- On Unix, stage identity is captured from the open file and cleanup compares device/inode before unlinking. A foreign replacement is never deleted merely because it occupies the expected pathname. +- On Unix, stage identity is captured from the open file and cleanup compares device/inode before unlinking. +- On Windows, stage identity is captured from the original handle with `GetFileInformationByHandleEx(FileIdInfo)` as the volume serial number plus 128-bit file id. Cleanup reopens the stage pathname and requires that exact identity before unlinking. A different regular file at the same pathname is therefore not cleanup authority. - Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. -## Hosted finding and repair +## Hosted findings and repairs Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a platform contract from test/workflow noise. macOS passed the owned Score Storage unit and publication regressions. Windows passed all three owned unit tests but failed both publication regressions: the success path returned `Could not attach the score PDF.`, and the duplicate-destination case left the stage pathname behind. The common cause was the Windows stage handle opened with `share_mode(0)` remaining live while `hard_link` or `remove_file` needed pathname mutation. -The repair keeps `share_mode(0)` during the untrusted write, captures the stage identity while the handle is live, calls `sync_all`, and drops the handle before publication or cleanup. No sharing flag was loosened merely to make the test pass. Unix retains device/inode-bound cleanup; Windows identity-bound cleanup after handle close remains a separate acceptance item. +The first repair kept `share_mode(0)` during the untrusted write, captured the stage identity while the handle was live, called `sync_all`, and dropped the handle before publication or cleanup. No sharing flag was loosened merely to make the test pass. -Microsoft's `FILE_ID_INFO` contract gives the eventual Windows identity model: volume serial number plus the 128-bit file identifier identifies a file on one computer. Rust's current Windows `MetadataExt::file_index`/`volume_serial_number` surface is still documented as nightly-only, so this PR does not pretend that stable Rust 1.97.1 already supplies an equivalent identity primitive. A later Windows cleanup repair must use an audited Win32 binding or another stable owner-approved mechanism and carry its own native regression. +That exposed a second Windows-specific cleanup authority defect: after the protected write handle closed, the non-Unix cleanup path only checked that the stage pathname currently named a regular file. RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the original stage after handle close and required cleanup to preserve the foreign file. Exact run `35452152802`, Windows job `105921067591`, checked out that exact SHA with Rust 1.97.1 and failed only `score_storage::tests::windows_cleanup_preserves_replaced_stage_path`; macOS job `105921067494` stayed green because the regression is Windows-only. + +GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` uses the Windows `FILE_ID_INFO` contract through a narrow `Kernel32` FFI call. Microsoft defines `FILE_ID_INFO` as a volume serial number plus 128-bit file identifier and states that the pair uniquely identifies a file on one computer. `FileIdInfo` is requested through `GetFileInformationByHandleEx`; the implementation compares that identity from the original open staging handle with the identity obtained after reopening the cleanup pathname. Stable Rust 1.97.1 does not need a nightly-only `MetadataExt` file-id surface or a new Windows dependency for this query. + +The repair is intentionally narrower than descriptor-relative deletion. After the reopened file identity is checked, the implementation still closes that handle and calls pathname-based `remove_file`; a replacement in that final compare-to-unlink interval remains residual TOCTOU risk. This PR does not claim to have eliminated that interval. ## Alternatives rejected -`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was also rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. +`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. A 64-bit Windows file index was also rejected as the cleanup identity contract because Microsoft exposes a 128-bit `FILE_ID_INFO` specifically for file identity and documents the volume-serial-plus-file-id pair as the comparison key. ## Security Notes @@ -41,15 +46,15 @@ Microsoft's `FILE_ID_INFO` contract gives the eventual Windows identity model: v **Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. Windows cleanup likewise preserves a replaced stage pathname when its `FILE_ID_INFO` identity no longer matches the captured stage. **Privacy.** New Unix score bytes request `0600` at first visibility. Windows uses native ACL inheritance and does not claim POSIX permission equivalence. -**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, and Tauri call-site wiring. Windows/macOS hosted exact-head results remain required before this PR can advance. +**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, Tauri call-site wiring, and Windows post-close stage-path replacement. Windows/macOS hosted exact-head results are required before this PR can advance. ## Remaining risk / claim boundary -This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, actual Windows ACL/inheritance acceptance, and Windows identity-bound cleanup after the staging handle closes remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. +This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, actual Windows ACL/inheritance acceptance, and descriptor-relative Windows deletion remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. @@ -57,6 +62,8 @@ The implementation is stacked on #865 because write-time publication and read-ti Microsoft. (2024, February 22). *FILE_ID_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_id_info +Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getfileinformationbyhandleex + Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html From 06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:07:08 +0900 Subject: [PATCH 027/166] test(score): require owned attachment deletion boundary --- .../core/tests/score_pdf_attachment_wiring.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs index 6417c85f0..662742d42 100644 --- a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs +++ b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs @@ -14,3 +14,18 @@ fn tauri_attachment_command_uses_score_storage_publication_boundary() { assert!(attach_source.contains("publish_score_pdf_attachment(")); assert!(!attach_source.contains("std::fs::copy(")); } + +#[test] +fn tauri_remove_command_uses_score_storage_deletion_boundary() { + let remove_start = TAURI_MAIN + .find("fn remove_score_pdf(") + .expect("remove_score_pdf command should exist"); + let main_start = TAURI_MAIN[remove_start..] + .find("fn main()") + .map(|offset| remove_start + offset) + .expect("main should follow remove_score_pdf command"); + let remove_source = &TAURI_MAIN[remove_start..main_start]; + + assert!(remove_source.contains("remove_score_pdf_attachment(")); + assert!(!remove_source.contains("std::fs::remove_file(")); +} From 09610fb26127408f818a754a42b75a083e0e5045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:13:12 +0900 Subject: [PATCH 028/166] fix(score): bind attachment deletion to owned OS authority --- apps/desktop/core/src/root.rs | 2 +- apps/desktop/core/src/score_storage.rs | 261 +++++++++++++++++- apps/desktop/src-tauri/src/main.rs | 13 +- .../score-attachment-publication.md | 36 ++- 4 files changed, 290 insertions(+), 22 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 61831b091..60cee49be 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -12,4 +12,4 @@ mod score_storage; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; -pub use score_storage::publish_score_pdf_attachment; +pub use score_storage::{publish_score_pdf_attachment, remove_score_pdf_attachment}; diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 96efad709..b8bb87cbc 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -6,6 +6,7 @@ use std::{ }; const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; +const SCORE_REMOVE_ERROR: &str = "Could not remove the score PDF."; const SCORE_TOO_LARGE_ERROR: &str = "Score PDF is too large (exceeds 25MB limit)."; const SCORE_INVALID_PDF_ERROR: &str = "The selected file is not a valid PDF."; const COPY_BUFFER_BYTES: usize = 64 * 1024; @@ -91,7 +92,7 @@ fn copy_bounded_pdf_stream( } #[cfg(unix)] -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] struct StageIdentity { device: u64, inode: u64, @@ -123,8 +124,30 @@ struct WindowsFileIdInfo { file_id: WindowsFileId128, } +#[cfg(windows)] +#[repr(C)] +struct WindowsFileDispositionInfo { + delete_file: u8, +} + #[cfg(windows)] const FILE_ID_INFO_CLASS: i32 = 0x12; +#[cfg(windows)] +const FILE_DISPOSITION_INFO_CLASS: i32 = 4; +#[cfg(windows)] +const DELETE_ACCESS: u32 = 0x0001_0000; +#[cfg(windows)] +const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080; +#[cfg(windows)] +const FILE_SHARE_READ: u32 = 0x0000_0001; +#[cfg(windows)] +const FILE_SHARE_WRITE: u32 = 0x0000_0002; +#[cfg(windows)] +const FILE_SHARE_DELETE: u32 = 0x0000_0004; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; #[cfg(windows)] #[link(name = "kernel32")] @@ -135,6 +158,12 @@ extern "system" { file_information: *mut std::ffi::c_void, buffer_size: u32, ) -> i32; + fn SetFileInformationByHandle( + file: *mut std::ffi::c_void, + file_information_class: i32, + file_information: *mut std::ffi::c_void, + buffer_size: u32, + ) -> i32; } #[cfg(windows)] @@ -216,6 +245,164 @@ fn remove_owned_stage(path: &Path, _expected: StageIdentity) -> Result<(), Strin fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) } +#[cfg(unix)] +const O_RDONLY: i32 = 0; +#[cfg(any(target_os = "linux", target_os = "android"))] +const O_NOFOLLOW: i32 = 0x0002_0000; +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] +const O_NOFOLLOW: i32 = 0x0000_0100; + +#[cfg(unix)] +extern "C" { + fn openat(dirfd: i32, pathname: *const std::os::raw::c_char, flags: i32) -> i32; + fn unlinkat(dirfd: i32, pathname: *const std::os::raw::c_char, flags: i32) -> i32; +} + +#[cfg(unix)] +fn open_score_entry_at(parent: &File, name: &std::ffi::CString) -> Result { + use std::os::fd::{AsRawFd, FromRawFd}; + + let fd = unsafe { openat(parent.as_raw_fd(), name.as_ptr(), O_RDONLY | O_NOFOLLOW) }; + if fd < 0 { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +#[cfg(unix)] +fn remove_score_pdf_attachment_with_hook(path: &Path, before_unlink: F) -> Result<(), String> +where + F: FnOnce(), +{ + use std::{ffi::CString, os::fd::AsRawFd, os::unix::ffi::OsStrExt}; + + let parent = path + .parent() + .ok_or_else(|| SCORE_REMOVE_ERROR.to_string())?; + let file_name = path + .file_name() + .ok_or_else(|| SCORE_REMOVE_ERROR.to_string())?; + let parent_file = File::open(parent).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !parent_file + .metadata() + .map_err(|_| SCORE_REMOVE_ERROR.to_string())? + .is_dir() + { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + let name = CString::new(file_name.as_bytes()).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + let target_file = open_score_entry_at(&parent_file, &name)?; + let target_metadata = target_file + .metadata() + .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !target_metadata.is_file() { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + let expected = stage_identity(&target_file).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + + before_unlink(); + + // Re-open by basename under the already-open parent directory. This keeps + // ancestor replacement out of the authority check. The remaining + // identity-check-to-unlinkat interval is documented as residual TOCTOU. + let current_file = open_score_entry_at(&parent_file, &name)?; + let current_metadata = current_file + .metadata() + .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !current_metadata.is_file() + || stage_identity(¤t_file).map_err(|_| SCORE_REMOVE_ERROR.to_string())? != expected + { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + + let result = unsafe { unlinkat(parent_file.as_raw_fd(), name.as_ptr(), 0) }; + if result != 0 { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn remove_score_pdf_attachment_with_hook(path: &Path, before_unlink: F) -> Result<(), String> +where + F: FnOnce(), +{ + use std::{mem::size_of, os::windows::fs::MetadataExt, os::windows::fs::OpenOptionsExt}; + use std::os::windows::io::AsRawHandle; + + let path_metadata = fs::symlink_metadata(path).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !path_metadata.is_file() + || path_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + + let mut options = OpenOptions::new(); + options + .access_mode(DELETE_ACCESS | FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(path) + .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + let opened_metadata = file + .metadata() + .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !opened_metadata.is_file() + || opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + + before_unlink(); + + let mut disposition = WindowsFileDispositionInfo { delete_file: 1 }; + let result = unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FILE_DISPOSITION_INFO_CLASS, + (&mut disposition as *mut WindowsFileDispositionInfo).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + drop(file); + Ok(()) +} + +#[cfg(all(not(unix), not(windows)))] +fn remove_score_pdf_attachment_with_hook(path: &Path, before_unlink: F) -> Result<(), String> +where + F: FnOnce(), +{ + let metadata = fs::symlink_metadata(path).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !metadata.is_file() { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + before_unlink(); + fs::remove_file(path).map_err(|_| SCORE_REMOVE_ERROR.to_string()) +} + +/// Remove one score attachment after the caller has resolved it inside the +/// app-owned score workspace. +/// +/// Windows opens the exact file object with DELETE authority and marks that +/// same handle for deletion with `SetFileInformationByHandle(FileDispositionInfo)`, +/// so a later pathname replacement cannot redirect deletion to a foreign file. +/// Unix pins the parent directory, opens the basename with `O_NOFOLLOW`, +/// rechecks device/inode identity through that directory descriptor, and then +/// calls `unlinkat`. Unix still has a narrow identity-check-to-`unlinkat` race; +/// callers must not treat this as a race-free object deletion primitive. +/// +/// Security Notes: no absolute path or file content is returned in errors. A +/// reparse/symlink entry, non-regular object, identity change, or OS deletion +/// failure is fail-closed. +pub fn remove_score_pdf_attachment(path: &Path) -> Result<(), String> { + remove_score_pdf_attachment_with_hook(path, || {}) +} + /// Publish one validated score PDF into the app-owned score workspace. /// /// The source is reopened once and copied from that descriptor with a fixed @@ -308,7 +495,15 @@ pub fn publish_score_pdf_attachment( #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; + use std::{io::Cursor, path::PathBuf, 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 bounded_stream_rejects_growth_after_length_snapshot() { @@ -349,16 +544,64 @@ mod tests { assert!(!error.contains("PK")); } + #[test] + fn score_attachment_delete_removes_authorized_regular_file() { + let root = unique_test_dir("score-delete"); + fs::create_dir_all(&root).expect("score root should be created"); + let target = root.join("score.pdf"); + fs::write(&target, b"%PDF-delete").expect("score fixture should be written"); + + remove_score_pdf_attachment(&target).expect("authorized score should be removed"); + + assert!(!target.exists()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn unix_delete_preserves_replacement_before_descriptor_relative_unlink() { + let root = unique_test_dir("score-delete-unix-replacement"); + fs::create_dir_all(&root).expect("score root should be created"); + let target = root.join("score.pdf"); + let moved = root.join("owned-before-replacement.pdf"); + fs::write(&target, b"%PDF-owned").expect("owned score should be written"); + + let error = remove_score_pdf_attachment_with_hook(&target, || { + fs::rename(&target, &moved).expect("owned score should move inside the same directory"); + fs::write(&target, b"%PDF-foreign").expect("foreign replacement should be written"); + }) + .expect_err("identity mismatch must fail closed before unlinkat"); + + assert_eq!(error, SCORE_REMOVE_ERROR); + assert_eq!(fs::read(&target).expect("replacement should survive"), b"%PDF-foreign"); + assert_eq!(fs::read(&moved).expect("owned file should survive failed deletion"), b"%PDF-owned"); + let _ = fs::remove_dir_all(root); + } + #[cfg(windows)] #[test] - fn windows_cleanup_preserves_replaced_stage_path() { - use std::time::{SystemTime, UNIX_EPOCH}; + fn windows_delete_marks_open_file_object_not_replacement_path() { + let root = unique_test_dir("score-delete-windows-replacement"); + fs::create_dir_all(&root).expect("score root should be created"); + let target = root.join("score.pdf"); + let moved = root.join("owned-before-replacement.pdf"); + fs::write(&target, b"%PDF-owned").expect("owned score should be written"); + + remove_score_pdf_attachment_with_hook(&target, || { + fs::rename(&target, &moved).expect("owned score should move while delete handle is open"); + fs::write(&target, b"%PDF-foreign").expect("foreign replacement should be written"); + }) + .expect("handle-bound disposition should delete only the originally opened score"); + + assert_eq!(fs::read(&target).expect("replacement should survive"), b"%PDF-foreign"); + assert!(!moved.exists(), "the originally opened score object should be deleted"); + let _ = fs::remove_dir_all(root); + } - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!("bandscope-score-stage-identity-{suffix}")); + #[cfg(windows)] + #[test] + fn windows_cleanup_preserves_replaced_stage_path() { + let root = unique_test_dir("score-stage-identity"); fs::create_dir_all(&root).expect("score root should be created"); let stage = root.join("stage.pdf"); let stage_file = create_private_stage(&stage).expect("owned stage should be created"); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c37d02c38..f6a7ccbcb 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -657,7 +657,6 @@ fn select_local_audio_source( source, }; store_bootstrap_source(&state, summary.clone()); - Ok(summary) } @@ -841,9 +840,11 @@ fn read_score_pdf( read_validated_score_pdf(&path) } -/// Security Notes: same id validation and traversal guard as `read_score_pdf`; -/// deletion is scoped to a single validated file inside the app-owned scores -/// root. Returns `false` when the score does not exist (idempotent removal). +/// Security Notes: same id validation and traversal guard as `read_score_pdf`. +/// Score Storage owns the final deletion authority: Windows marks the opened +/// file object for deletion by handle; Unix pins the parent directory and +/// revalidates device/inode before descriptor-relative `unlinkat`. +/// Returns `false` when the score does not exist (idempotent removal). #[tauri::command] fn remove_score_pdf( project_id: String, @@ -861,7 +862,7 @@ fn remove_score_pdf( Ok(path) => path, Err(_) => return Ok(false), }; - std::fs::remove_file(path).map_err(|_| "Could not remove the score PDF.".to_string())?; + remove_score_pdf_attachment(&path)?; Ok(true) } @@ -881,4 +882,4 @@ fn main() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 6b93de97f..d8d29e65a 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -7,7 +7,9 @@ Canonical owner: Score Storage / Score Attachment The desktop command admitted a selected PDF and then copied it into `/scores` with `std::fs::copy`. That left the buyer-visible write contract implicit: first-visible Unix mode depended on copy/platform behavior, the source could change after path validation, an existing destination had no explicit no-clobber invariant, and partial-copy cleanup was not owned by a Score Storage boundary. -The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This slice owns only score attachment publication and its lifecycle semantics. +The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This slice owns score attachment publication and its lifecycle semantics. + +Deletion had a separate authority gap. `remove_score_pdf` first resolved and canonicalized `.pdf`, then later called pathname-based `std::fs::remove_file`. A local replacement between those operations could make validation apply to one object while deletion applied to another object at the same name. ## Decision @@ -24,6 +26,12 @@ The native read-time 25 MiB allocation/content guard remains #865. Project/works - On Windows, stage identity is captured from the original handle with `GetFileInformationByHandleEx(FileIdInfo)` as the volume serial number plus 128-bit file id. Cleanup reopens the stage pathname and requires that exact identity before unlinking. A different regular file at the same pathname is therefore not cleanup authority. - Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. +`remove_score_pdf_attachment` owns the final score-file deletion authority after the caller has resolved the id inside the app-owned score root. + +- Windows opens the exact file object with `DELETE | FILE_READ_ATTRIBUTES`, allows normal read/write/delete sharing, and uses `FILE_FLAG_OPEN_REPARSE_POINT` so a late reparse replacement is not followed. `SetFileInformationByHandle(FileDispositionInfo)` marks that same open file object for deletion. Microsoft requires `DELETE` access for this information class and documents deletion on handle close. +- Unix opens and pins the parent directory, opens the score basename through that directory with `openat(..., O_NOFOLLOW)`, captures device/inode from the opened file, and reopens the basename under the same parent descriptor before `unlinkat`. A replacement observed before that second identity check is preserved and deletion fails closed. +- Unix does **not** claim race-free object deletion: there remains a narrow identity-check-to-`unlinkat` basename replacement interval because portable POSIX `unlinkat` removes a directory entry, not an already-open file object. The stable parent descriptor removes ancestor-substitution authority but not that final name race. + ## Hosted findings and repairs Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a platform contract from test/workflow noise. macOS passed the owned Score Storage unit and publication regressions. Windows passed all three owned unit tests but failed both publication regressions: the success path returned `Could not attach the score PDF.`, and the duplicate-destination case left the stage pathname behind. The common cause was the Windows stage handle opened with `share_mode(0)` remaining live while `hard_link` or `remove_file` needed pathname mutation. @@ -34,27 +42,35 @@ That exposed a second Windows-specific cleanup authority defect: after the prote GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` uses the Windows `FILE_ID_INFO` contract through a narrow `Kernel32` FFI call. Microsoft defines `FILE_ID_INFO` as a volume serial number plus 128-bit file identifier and states that the pair uniquely identifies a file on one computer. `FileIdInfo` is requested through `GetFileInformationByHandleEx`; the implementation compares that identity from the original open staging handle with the identity obtained after reopening the cleanup pathname. Stable Rust 1.97.1 does not need a nightly-only `MetadataExt` file-id surface or a new Windows dependency for this query. -The repair is intentionally narrower than descriptor-relative deletion. After the reopened file identity is checked, the implementation still closes that handle and calls pathname-based `remove_file`; a replacement in that final compare-to-unlink interval remains residual TOCTOU risk. This PR does not claim to have eliminated that interval. +The stage-cleanup repair is intentionally narrower than descriptor-relative deletion. After the reopened file identity is checked, the implementation still closes that handle and calls pathname-based `remove_file`; a replacement in that final compare-to-unlink interval remains residual TOCTOU risk. This PR does not claim to have eliminated that interval. + +Deletion RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` added a Tauri wiring contract requiring `remove_score_pdf` to consume the Score Storage deletion boundary rather than calling `std::fs::remove_file` directly. Exact `score-storage-native` run `35453934315` failed on both native lanes after all owned unit tests passed: macOS job `105925792583` and Windows Server 2025 job `105925792679` failed the publication/wiring regression step. This isolates the product-wiring authority gap rather than a compiler or dependency failure. + +The deletion repair moves the final operation behind `remove_score_pdf_attachment`. Windows uses handle-bound `FileDispositionInfo`; a native replacement regression renames the opened score and creates a foreign file at the original pathname before disposition, and requires the foreign replacement to survive while only the originally opened object is deleted. Unix performs the analogous replacement before its second descriptor-relative identity check and requires fail-closed preservation of both the foreign replacement and the moved original. ## Alternatives rejected `std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. A 64-bit Windows file index was also rejected as the cleanup identity contract because Microsoft exposes a 128-bit `FILE_ID_INFO` specifically for file identity and documents the volume-serial-plus-file-id pair as the comparison key. +For retention deletion, a second pathname `stat`/metadata check was rejected because it only moves the race. Windows supports a stronger object-bound operation through a handle with `DELETE` authority, so pathname unlink is unnecessary there. On Unix, reopening by absolute pathname after validation was rejected in favor of pinning the parent directory and using `openat`/`unlinkat`; this narrows authority to the admitted directory even though portable `unlinkat` still cannot guarantee object-bound deletion across the last identity-check/name-removal interval. + ## Security Notes **Untrusted input.** The selected local PDF and any pre-existing pathname in the score workspace are untrusted. The file-dialog path itself is not sent by the WebView. -**Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. +**Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. Removal is validated score id → canonical in-root path → Score Storage OS-object deletion boundary. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. Windows cleanup likewise preserves a replaced stage pathname when its `FILE_ID_INFO` identity no longer matches the captured stage. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, reparse/symlink object, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. Windows stage cleanup likewise preserves a replaced stage pathname when its `FILE_ID_INFO` identity no longer matches the captured stage. Retention deletion preserves a foreign replacement in the tested Windows handle-bound and Unix pre-`unlinkat` replacement cases. **Privacy.** New Unix score bytes request `0600` at first visibility. Windows uses native ACL inheritance and does not claim POSIX permission equivalence. -**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, Tauri call-site wiring, and Windows post-close stage-path replacement. Windows/macOS hosted exact-head results are required before this PR can advance. +**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, Tauri call-site wiring, Windows post-close stage-path replacement, ordinary authorized deletion, Windows foreign-path replacement after handle acquisition, and Unix foreign-path replacement before the second descriptor-relative identity check. Windows/macOS hosted exact-head results are required before this PR can advance. ## Remaining risk / claim boundary -This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy, actual Windows ACL/inheritance acceptance, and descriptor-relative Windows deletion remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. +This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy beyond explicit attachment removal, actual Windows ACL/inheritance acceptance, and stage-cleanup descriptor-relative deletion remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. + +Windows retention deletion is object-bound to the opened file handle for the tested local filesystem contract. Unix retention deletion pins the parent descriptor and verifies device/inode through `openat`, but the final identity-check-to-`unlinkat` interval remains a documented residual race. A future threat-model decision may require a platform-specific stronger primitive or a storage layout that makes untrusted concurrent pathname replacement impossible. The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. @@ -64,8 +80,16 @@ Microsoft. (2024, February 22). *FILE_ID_INFO structure (winbase.h)*. Microsoft Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getfileinformationbyhandleex +Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info + +Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle + Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea +The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html + +The Open Group. (2024). *open, openat — open file relative to directory file descriptor*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html From 89f136e312ded829b89f2beafda68ca1c373044c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:19:21 +0900 Subject: [PATCH 029/166] test(score): expose Windows stage cleanup pathname race --- apps/desktop/core/src/score_storage.rs | 71 +++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index b8bb87cbc..0b68efe62 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -222,7 +222,14 @@ fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String } #[cfg(windows)] -fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { +fn remove_owned_stage_with_hook( + path: &Path, + expected: StageIdentity, + before_unlink: F, +) -> Result<(), String> +where + F: FnOnce(), +{ let current_metadata = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !current_metadata.is_file() { @@ -233,9 +240,15 @@ fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String return Err(SCORE_ATTACH_ERROR.to_string()); } drop(current_file); + before_unlink(); fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) } +#[cfg(windows)] +fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { + remove_owned_stage_with_hook(path, expected, || {}) +} + #[cfg(all(not(unix), not(windows)))] fn remove_owned_stage(path: &Path, _expected: StageIdentity) -> Result<(), String> { let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; @@ -362,7 +375,7 @@ where file.as_raw_handle(), FILE_DISPOSITION_INFO_CLASS, (&mut disposition as *mut WindowsFileDispositionInfo).cast(), - size_of::() as u32, + std::mem::size_of::() as u32, ) }; if result == 0 { @@ -495,7 +508,11 @@ pub fn publish_score_pdf_attachment( #[cfg(test)] mod tests { use super::*; - use std::{io::Cursor, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + io::Cursor, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() @@ -573,8 +590,14 @@ mod tests { .expect_err("identity mismatch must fail closed before unlinkat"); assert_eq!(error, SCORE_REMOVE_ERROR); - assert_eq!(fs::read(&target).expect("replacement should survive"), b"%PDF-foreign"); - assert_eq!(fs::read(&moved).expect("owned file should survive failed deletion"), b"%PDF-owned"); + assert_eq!( + fs::read(&target).expect("replacement should survive"), + b"%PDF-foreign" + ); + assert_eq!( + fs::read(&moved).expect("owned file should survive failed deletion"), + b"%PDF-owned" + ); let _ = fs::remove_dir_all(root); } @@ -593,8 +616,42 @@ mod tests { }) .expect("handle-bound disposition should delete only the originally opened score"); - assert_eq!(fs::read(&target).expect("replacement should survive"), b"%PDF-foreign"); - assert!(!moved.exists(), "the originally opened score object should be deleted"); + assert_eq!( + fs::read(&target).expect("replacement should survive"), + b"%PDF-foreign" + ); + assert!( + !moved.exists(), + "the originally opened score object should be deleted" + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn windows_stage_cleanup_deletes_owned_object_not_replacement_path() { + let root = unique_test_dir("score-stage-cleanup-replacement"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join("stage.pdf"); + let moved = root.join("owned-before-replacement.pdf"); + let stage_file = create_private_stage(&stage).expect("owned stage should be created"); + let expected = stage_identity(&stage_file).expect("owned stage identity should be captured"); + drop(stage_file); + + remove_owned_stage_with_hook(&stage, expected, || { + fs::rename(&stage, &moved).expect("owned stage should move after identity check"); + fs::write(&stage, b"foreign replacement").expect("foreign replacement should be written"); + }) + .expect("cleanup should delete the captured stage object, not the replacement pathname"); + + assert_eq!( + fs::read(&stage).expect("foreign replacement must survive cleanup"), + b"foreign replacement" + ); + assert!( + !moved.exists(), + "the captured stage object should be deleted through its original authority" + ); let _ = fs::remove_dir_all(root); } From d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:23:16 +0900 Subject: [PATCH 030/166] fix(score): delete Windows stages through owned handles --- apps/desktop/core/src/score_storage.rs | 107 +++++++++--------- .../score-attachment-publication.md | 91 +++++++++------ 2 files changed, 110 insertions(+), 88 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 0b68efe62..78734ce46 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -201,6 +201,51 @@ fn stage_identity(file: &File) -> Result { }) } +#[cfg(windows)] +fn open_windows_delete_handle(path: &Path, error: &str) -> Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + let path_metadata = fs::symlink_metadata(path).map_err(|_| error.to_string())?; + if !path_metadata.is_file() + || path_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(error.to_string()); + } + + let mut options = OpenOptions::new(); + options + .access_mode(DELETE_ACCESS | FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options.open(path).map_err(|_| error.to_string())?; + let opened_metadata = file.metadata().map_err(|_| error.to_string())?; + if !opened_metadata.is_file() + || opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(error.to_string()); + } + Ok(file) +} + +#[cfg(windows)] +fn mark_windows_handle_for_deletion(file: &File, error: &str) -> Result<(), String> { + use std::{mem::size_of, os::windows::io::AsRawHandle}; + + let mut disposition = WindowsFileDispositionInfo { delete_file: 1 }; + let result = unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FILE_DISPOSITION_INFO_CLASS, + (&mut disposition as *mut WindowsFileDispositionInfo).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(error.to_string()); + } + Ok(()) +} + #[cfg(all(not(unix), not(windows)))] #[derive(Clone, Copy)] struct StageIdentity; @@ -230,18 +275,16 @@ fn remove_owned_stage_with_hook( where F: FnOnce(), { - let current_metadata = - fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; - if !current_metadata.is_file() { - return Err(SCORE_ATTACH_ERROR.to_string()); - } - let current_file = File::open(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let current_file = open_windows_delete_handle(path, SCORE_ATTACH_ERROR)?; if stage_identity(¤t_file)? != expected { return Err(SCORE_ATTACH_ERROR.to_string()); } - drop(current_file); + before_unlink(); - fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) + + mark_windows_handle_for_deletion(¤t_file, SCORE_ATTACH_ERROR)?; + drop(current_file); + Ok(()) } #[cfg(windows)] @@ -340,47 +383,11 @@ fn remove_score_pdf_attachment_with_hook(path: &Path, before_unlink: F) -> Re where F: FnOnce(), { - use std::{mem::size_of, os::windows::fs::MetadataExt, os::windows::fs::OpenOptionsExt}; - use std::os::windows::io::AsRawHandle; - - let path_metadata = fs::symlink_metadata(path).map_err(|_| SCORE_REMOVE_ERROR.to_string())?; - if !path_metadata.is_file() - || path_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 - { - return Err(SCORE_REMOVE_ERROR.to_string()); - } - - let mut options = OpenOptions::new(); - options - .access_mode(DELETE_ACCESS | FILE_READ_ATTRIBUTES) - .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - let file = options - .open(path) - .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; - let opened_metadata = file - .metadata() - .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; - if !opened_metadata.is_file() - || opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 - { - return Err(SCORE_REMOVE_ERROR.to_string()); - } + let file = open_windows_delete_handle(path, SCORE_REMOVE_ERROR)?; before_unlink(); - let mut disposition = WindowsFileDispositionInfo { delete_file: 1 }; - let result = unsafe { - SetFileInformationByHandle( - file.as_raw_handle(), - FILE_DISPOSITION_INFO_CLASS, - (&mut disposition as *mut WindowsFileDispositionInfo).cast(), - std::mem::size_of::() as u32, - ) - }; - if result == 0 { - return Err(SCORE_REMOVE_ERROR.to_string()); - } + mark_windows_handle_for_deletion(&file, SCORE_REMOVE_ERROR)?; drop(file); Ok(()) } @@ -428,11 +435,9 @@ pub fn remove_score_pdf_attachment(path: &Path) -> Result<(), String> { /// /// Security Notes: errors never include the source path or PDF bytes. Unix /// cleanup compares device/inode identity captured from the open stage. Windows -/// captures the volume serial plus 128-bit `FILE_ID_INFO` from the original -/// handle and requires the same identity after reopening the stage pathname, so -/// a foreign replacement is not accepted merely because it is a regular file. -/// The final identity-check-to-unlink interval is still pathname based and is -/// not claimed as descriptor-relative deletion. +/// opens the exact stage object with DELETE authority, verifies its volume plus +/// 128-bit file id against the captured identity, then marks that same handle +/// for deletion so a late pathname replacement cannot redirect cleanup. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index d8d29e65a..098bc0f7a 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -1,78 +1,97 @@ -# Score attachment publication +# Score attachment publication and retention Issue: #1239 Canonical owner: Score Storage / Score Attachment ## Problem -The desktop command admitted a selected PDF and then copied it into `/scores` with `std::fs::copy`. That left the buyer-visible write contract implicit: first-visible Unix mode depended on copy/platform behavior, the source could change after path validation, an existing destination had no explicit no-clobber invariant, and partial-copy cleanup was not owned by a Score Storage boundary. +The desktop attachment command originally admitted a PDF and copied it into `/scores` with `std::fs::copy`. That left first-visible permissions, source mutation during copy, no-clobber publication, partial-copy cleanup, and retention semantics implicit. -The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This slice owns score attachment publication and its lifecycle semantics. +The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This lane owns score attachment publication and explicit attachment removal only. -Deletion had a separate authority gap. `remove_score_pdf` first resolved and canonicalized `.pdf`, then later called pathname-based `std::fs::remove_file`. A local replacement between those operations could make validation apply to one object while deletion applied to another object at the same name. +Deletion had a separate authority gap. `remove_score_pdf` first resolved and canonicalized `.pdf`, then later called pathname-based `std::fs::remove_file`. A local replacement between validation and deletion could make those operations refer to different file objects. -## Decision +Publication cleanup also retained one Windows gap after the first identity repair: cleanup reopened the stage, compared `FILE_ID_INFO`, closed that handle, then called pathname-based `remove_file`. A replacement after the identity check could redirect cleanup to a foreign file. + +## Publication decision `publish_score_pdf_attachment` is the native Score Storage publication boundary. - Reopen the already-admitted source once and bind copy to that descriptor. -- Snapshot descriptor length and reject `0` or `> 25 MiB` before copying. +- Snapshot descriptor length and reject `0` or `> 25 MiB` before copy. - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. -- Revalidate `%PDF-` on the bytes that are actually copied. -- Stage with `create_new`. Rust documents `create_new` as atomic with respect to an existing target; Unix `OpenOptionsExt::mode` supplies the creation mode before `umask` is applied. Unix therefore requests `0600` instead of creating broad permissions and tightening them later. -- Windows intentionally uses the app-owned scores directory ACL/inheritance model rather than claiming POSIX-equivalent `0600`. Its staging handle denies sharing while bytes are written, is synchronized, and is then closed before hard-link publication and stage cleanup. -- Publish with a hard link from the synchronized stage to `.pdf`, so an existing score id is not overwritten. -- On Unix, stage identity is captured from the open file and cleanup compares device/inode before unlinking. -- On Windows, stage identity is captured from the original handle with `GetFileInformationByHandleEx(FileIdInfo)` as the volume serial number plus 128-bit file id. Cleanup reopens the stage pathname and requires that exact identity before unlinking. A different regular file at the same pathname is therefore not cleanup authority. -- Return the byte count from the descriptor-bound copy to IPC rather than trusting the earlier path-validation size snapshot. +- Revalidate `%PDF-` on the bytes actually copied. +- Stage with `create_new`. Unix requests `0600` at first visibility rather than creating broad permissions and tightening them later. +- Windows inherits the app-owned scores-directory ACL and denies sharing during the untrusted write. The synchronized stage handle is closed before hard-link publication because pathname mutation is intentionally denied while that handle is live. +- Publish with a hard link to `.pdf` so an existing attachment is never overwritten. +- Return the byte count from the descriptor-bound copy rather than the earlier path-validation size snapshot. + +### Stage cleanup authority + +On Unix, stage identity is device/inode from the owned stage. Cleanup compares that identity before pathname unlink. This still has a documented final identity-check-to-unlink race. -`remove_score_pdf_attachment` owns the final score-file deletion authority after the caller has resolved the id inside the app-owned score root. +On Windows, cleanup now uses the same object-bound deletion model as explicit retention removal. It opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_FLAG_OPEN_REPARSE_POINT`, and normal read/write/delete sharing; rejects reparse/non-regular objects; verifies `FILE_ID_INFO` volume serial + 128-bit file id against the identity captured from the original staging handle; then calls `SetFileInformationByHandle(FileDispositionInfo)` on that same open handle. A later pathname replacement cannot redirect cleanup to the replacement. -- Windows opens the exact file object with `DELETE | FILE_READ_ATTRIBUTES`, allows normal read/write/delete sharing, and uses `FILE_FLAG_OPEN_REPARSE_POINT` so a late reparse replacement is not followed. `SetFileInformationByHandle(FileDispositionInfo)` marks that same open file object for deletion. Microsoft requires `DELETE` access for this information class and documents deletion on handle close. -- Unix opens and pins the parent directory, opens the score basename through that directory with `openat(..., O_NOFOLLOW)`, captures device/inode from the opened file, and reopens the basename under the same parent descriptor before `unlinkat`. A replacement observed before that second identity check is preserved and deletion fails closed. -- Unix does **not** claim race-free object deletion: there remains a narrow identity-check-to-`unlinkat` basename replacement interval because portable POSIX `unlinkat` removes a directory entry, not an already-open file object. The stable parent descriptor removes ancestor-substitution authority but not that final name race. +## Retention/delete decision + +`remove_score_pdf_attachment` owns the final score-file deletion authority after the caller resolves the id inside the app-owned score root. + +- Windows opens the exact file object with `DELETE | FILE_READ_ATTRIBUTES`, rejects reparse objects, and marks that handle with `SetFileInformationByHandle(FileDispositionInfo)`. Microsoft documents `FILE_DISPOSITION_INFO.DeleteFile` as the request to delete the file and requires DELETE authority for this information class. +- Unix pins the parent directory, opens the score basename with `openat(..., O_NOFOLLOW)`, captures device/inode, then reopens the basename under the same parent descriptor immediately before `unlinkat`. +- A Unix replacement observed before the second identity check is preserved and deletion fails closed. +- Portable POSIX `unlinkat` removes a directory entry rather than an arbitrary already-open object, so the final identity-check-to-`unlinkat` basename interval remains a documented residual race. Pinning the parent removes ancestor substitution but does not erase that last name race. ## Hosted findings and repairs -Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a platform contract from test/workflow noise. macOS passed the owned Score Storage unit and publication regressions. Windows passed all three owned unit tests but failed both publication regressions: the success path returned `Could not attach the score PDF.`, and the duplicate-destination case left the stage pathname behind. The common cause was the Windows stage handle opened with `share_mode(0)` remaining live while `hard_link` or `remove_file` needed pathname mutation. +### Publication and first Windows cleanup repair + +Initial publication RED `f8ba40d10458ed6b7b3b0e9d4d8f79ec866c50df` added the publication contract before production code. That source-level RED was superseded before a hosted terminal compiler verdict, so no hosted claim is made for it. -The first repair kept `share_mode(0)` during the untrusted write, captured the stage identity while the handle was live, called `sync_all`, and dropped the handle before publication or cleanup. No sharing flag was loosened merely to make the test pass. +Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a Windows platform contract from workflow noise. macOS passed the owned Score Storage regressions. Windows passed the owned unit tests but failed publication because a `share_mode(0)` stage handle was still live while hard-link/unlink needed pathname mutation. The repair keeps exclusive sharing during untrusted write, captures stage identity, synchronizes, then closes the handle before publication. -That exposed a second Windows-specific cleanup authority defect: after the protected write handle closed, the non-Unix cleanup path only checked that the stage pathname currently named a regular file. RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the original stage after handle close and required cleanup to preserve the foreign file. Exact run `35452152802`, Windows job `105921067591`, checked out that exact SHA with Rust 1.97.1 and failed only `score_storage::tests::windows_cleanup_preserves_replaced_stage_path`; macOS job `105921067494` stayed green because the regression is Windows-only. +A second Windows cleanup defect remained: post-close cleanup accepted any regular file at the stage pathname after its earlier identity assumptions. RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the stage with a foreign regular file. Run `35452152802`, Windows job `105921067591`, failed the dedicated regression; macOS job `105921067494` stayed green. GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` switched the comparison identity to `GetFileInformationByHandleEx(FileIdInfo)`, using volume serial plus 128-bit file id. -GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` uses the Windows `FILE_ID_INFO` contract through a narrow `Kernel32` FFI call. Microsoft defines `FILE_ID_INFO` as a volume serial number plus 128-bit file identifier and states that the pair uniquely identifies a file on one computer. `FileIdInfo` is requested through `GetFileInformationByHandleEx`; the implementation compares that identity from the original open staging handle with the identity obtained after reopening the cleanup pathname. Stable Rust 1.97.1 does not need a nightly-only `MetadataExt` file-id surface or a new Windows dependency for this query. +### Product deletion authority -The stage-cleanup repair is intentionally narrower than descriptor-relative deletion. After the reopened file identity is checked, the implementation still closes that handle and calls pathname-based `remove_file`; a replacement in that final compare-to-unlink interval remains residual TOCTOU risk. This PR does not claim to have eliminated that interval. +RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` added a Tauri wiring contract requiring `remove_score_pdf` to use a Score Storage deletion boundary rather than direct `std::fs::remove_file`. Exact run `35453934315` failed on both native lanes after owned unit tests passed: macOS job `105925792583` and Windows Server 2025 job `105925792679` failed the publication/wiring regression step. -Deletion RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` added a Tauri wiring contract requiring `remove_score_pdf` to consume the Score Storage deletion boundary rather than calling `std::fs::remove_file` directly. Exact `score-storage-native` run `35453934315` failed on both native lanes after all owned unit tests passed: macOS job `105925792583` and Windows Server 2025 job `105925792679` failed the publication/wiring regression step. This isolates the product-wiring authority gap rather than a compiler or dependency failure. +GREEN `09610fb26127408f818a754a42b75a083e0e5045` routed the command through `remove_score_pdf_attachment`. Run `35454259017` was terminal-success on exact current source at that point: macOS job `105926649166` and Windows job `105926649120` both passed owned unit and publication/wiring regressions. -The deletion repair moves the final operation behind `remove_score_pdf_attachment`. Windows uses handle-bound `FileDispositionInfo`; a native replacement regression renames the opened score and creates a foreign file at the original pathname before disposition, and requires the foreign replacement to survive while only the originally opened object is deleted. Unix performs the analogous replacement before its second descriptor-relative identity check and requires fail-closed preservation of both the foreign replacement and the moved original. +The Windows retention regression acquires the delete handle, renames the opened score, creates a foreign file at the old pathname, then marks the open handle for deletion. The replacement survives and only the originally opened object is deleted. The Unix counterpart replaces the basename before the second descriptor-relative identity check and requires fail-closed preservation of both files. + +### Windows stage cleanup final-name race + +RED `89f136e312ded829b89f2beafda68ca1c373044c` inserted a hook after Windows stage identity validation but before the old pathname unlink. The hook renames the owned stage and creates a foreign file at the former stage pathname. Exact run `35454589087` behaved as expected: macOS job `105927525260` stayed **SUCCESS** because the regression is Windows-only; Windows Server 2025 job `105927525168` failed in the owned Score Storage unit-test step. This is hosted RED evidence that identity-check-then-pathname-unlink was still redirectable. + +The repair keeps the identity-matched stage handle open with DELETE authority and applies `FileDispositionInfo` to that exact object. The same helper is used by retention deletion, avoiding two divergent Windows delete contracts. The regression now requires the foreign replacement to survive while the captured stage object is deleted. ## Alternatives rejected -`std::fs::copy` was rejected because it does not express the Score Storage invariants above as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting an existing UUID destination was rejected even though collision probability is very small: storage correctness must not rely on UUID probability when a no-clobber primitive is available. Allowing Windows path sharing during the write was rejected as the first repair for the hosted failure; closing only after `sync_all` keeps the stronger write-phase boundary and makes the completed stage publishable. A 64-bit Windows file index was also rejected as the cleanup identity contract because Microsoft exposes a 128-bit `FILE_ID_INFO` specifically for file identity and documents the volume-serial-plus-file-id pair as the comparison key. +`std::fs::copy` was rejected because it does not express the publication invariants as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting a UUID destination was rejected because correctness must not rely on collision probability when a no-clobber primitive exists. -For retention deletion, a second pathname `stat`/metadata check was rejected because it only moves the race. Windows supports a stronger object-bound operation through a handle with `DELETE` authority, so pathname unlink is unnecessary there. On Unix, reopening by absolute pathname after validation was rejected in favor of pinning the parent directory and using `openat`/`unlinkat`; this narrows authority to the admitted directory even though portable `unlinkat` still cannot guarantee object-bound deletion across the last identity-check/name-removal interval. +A second pathname `stat` before deletion was rejected because it only moves the race. Windows provides an object-bound delete operation through a handle with DELETE authority, so closing the identity handle and later unlinking the name is unnecessary. For Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`; this narrows authority while retaining an explicit residual POSIX name race. + +Weakening the Windows staging write share mode was also rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. ## Security Notes -**Untrusted input.** The selected local PDF and any pre-existing pathname in the score workspace are untrusted. The file-dialog path itself is not sent by the WebView. +**Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. -**Trust boundaries.** OS-selected path → existing score source admission → one opened source descriptor → app-owned Score Storage staging file → no-clobber buyer-visible attachment. Removal is validated score id → canonical in-root path → Score Storage OS-object deletion boundary. +**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber attachment. Explicit removal is validated in-root score path → Score Storage OS-object deletion boundary. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy error, sync error, reparse/symlink object, or identity mismatch returns a payload-safe error. The implementation does not log or return PDF bytes or absolute source paths. After a successful hard link, an unexpected destination metadata mismatch is preserved rather than blindly unlinked because a pathname swap could make that destination foreign. Windows stage cleanup likewise preserves a replaced stage pathname when its `FILE_ID_INFO` identity no longer matches the captured stage. Retention deletion preserves a foreign replacement in the tested Windows handle-bound and Unix pre-`unlinkat` replacement cases. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink object, or identity mismatch fails closed with payload-safe diagnostics. Unexpected foreign replacements are preserved in the tested Windows object-bound races and Unix pre-`unlinkat` replacement case. -**Privacy.** New Unix score bytes request `0600` at first visibility. Windows uses native ACL inheritance and does not claim POSIX permission equivalence. +**Privacy.** Unix publication requests `0600` at first visibility. Windows uses actual parent ACL inheritance; no POSIX-equivalence claim is made. -**Test points.** Core tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth, descriptor truncation, wrong magic, Tauri call-site wiring, Windows post-close stage-path replacement, ordinary authorized deletion, Windows foreign-path replacement after handle acquisition, and Unix foreign-path replacement before the second descriptor-relative identity check. Windows/macOS hosted exact-head results are required before this PR can advance. +**Test points.** Core/native tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. ## Remaining risk / claim boundary -This change does not prove packaged app crash/power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, detach retention policy beyond explicit attachment removal, actual Windows ACL/inheritance acceptance, and stage-cleanup descriptor-relative deletion remain acceptance work under #1239. Current path/workspace authority also depends on the eventual protected integration/reconciliation of #970; this PR does not copy Project Persistence directory-authority code. +This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, actual Windows ACL/inheritance acceptance, and Project Persistence #970 workspace reconciliation remain open. -Windows retention deletion is object-bound to the opened file handle for the tested local filesystem contract. Unix retention deletion pins the parent descriptor and verifies device/inode through `openat`, but the final identity-check-to-`unlinkat` interval remains a documented residual race. A future threat-model decision may require a platform-specific stronger primitive or a storage layout that makes untrusted concurrent pathname replacement impossible. +Windows explicit removal and Windows stage cleanup are object-bound to the handles used by `FileDispositionInfo` for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup still retain final pathname/name races unless a stronger platform-specific primitive or storage invariant is adopted. Those risks must remain visible in the threat model rather than being described as race-free. -The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface, but #865 remains the owner of reads. #1239 must be non-force reconciled after #865 and the relevant Project Persistence prerequisite integrate; predecessor CI evidence is not transferable to a restacked head. +The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 remains the owner of reads and must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. ## References @@ -88,10 +107,8 @@ Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https:// The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html -The Open Group. (2024). *open, openat — open file relative to directory file descriptor*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html +The Open Group. (2024). *open, openat — open file relative to a directory file descriptor*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html - -The Rust Project Developers. (2026). *MetadataExt — std::os::windows::fs*. Rust documentation. https://doc.rust-lang.org/beta/std/os/windows/fs/trait.MetadataExt.html From 579a67d4767d098d0dd608b5678d59e66efe1701 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:30:21 +0900 Subject: [PATCH 031/166] test(score): verify Windows ACL inheritance on publication --- .../tests/score_pdf_attachment_publication.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_attachment_publication.rs b/apps/desktop/core/tests/score_pdf_attachment_publication.rs index e8d92974a..5a6d394a4 100644 --- a/apps/desktop/core/tests/score_pdf_attachment_publication.rs +++ b/apps/desktop/core/tests/score_pdf_attachment_publication.rs @@ -95,3 +95,149 @@ fn score_attachment_publication_is_private_under_permissive_umask() { assert!(status.success(), "umask child regression should pass"); let _ = std::fs::remove_dir_all(root); } + +#[cfg(windows)] +fn windows_dacl_inheritance(path: &std::path::Path) -> (u16, u32) { + use std::{ffi::c_void, mem::size_of, os::windows::ffi::OsStrExt, ptr}; + + #[repr(C)] + struct AclSizeInformation { + ace_count: u32, + acl_bytes_in_use: u32, + acl_bytes_free: u32, + } + + #[repr(C)] + struct AceHeader { + ace_type: u8, + ace_flags: u8, + ace_size: u16, + } + + const SE_FILE_OBJECT: u32 = 1; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const ACL_SIZE_INFORMATION_CLASS: u32 = 2; + const INHERITED_ACE: u8 = 0x10; + + #[link(name = "advapi32")] + extern "system" { + fn GetNamedSecurityInfoW( + object_name: *const u16, + object_type: u32, + security_info: u32, + owner: *mut *mut c_void, + group: *mut *mut c_void, + dacl: *mut *mut c_void, + sacl: *mut *mut c_void, + security_descriptor: *mut *mut c_void, + ) -> u32; + fn GetSecurityDescriptorControl( + security_descriptor: *mut c_void, + control: *mut u16, + revision: *mut u32, + ) -> i32; + fn GetAclInformation( + acl: *const c_void, + information: *mut c_void, + information_length: u32, + information_class: u32, + ) -> i32; + fn GetAce(acl: *const c_void, ace_index: u32, ace: *mut *mut c_void) -> i32; + } + + #[link(name = "kernel32")] + extern "system" { + fn LocalFree(memory: *mut c_void) -> *mut c_void; + } + + let wide_path: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut dacl: *mut c_void = ptr::null_mut(); + let mut security_descriptor: *mut c_void = ptr::null_mut(); + let status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut security_descriptor, + ) + }; + assert_eq!(status, 0, "Windows should return the published file DACL"); + assert!( + !security_descriptor.is_null() && !dacl.is_null(), + "published score should have a concrete DACL" + ); + + let mut control = 0_u16; + let mut revision = 0_u32; + let control_ok = unsafe { + GetSecurityDescriptorControl(security_descriptor, &mut control, &mut revision) + }; + assert_ne!(control_ok, 0, "security descriptor control should be readable"); + + let mut acl_info = AclSizeInformation { + ace_count: 0, + acl_bytes_in_use: 0, + acl_bytes_free: 0, + }; + let acl_ok = unsafe { + GetAclInformation( + dacl, + (&mut acl_info as *mut AclSizeInformation).cast(), + size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + ) + }; + assert_ne!(acl_ok, 0, "published score ACL metadata should be readable"); + + let mut inherited_ace_count = 0_u32; + for index in 0..acl_info.ace_count { + let mut ace: *mut c_void = ptr::null_mut(); + let ace_ok = unsafe { GetAce(dacl, index, &mut ace) }; + assert_ne!(ace_ok, 0, "published score ACE should be readable"); + assert!(!ace.is_null(), "published score ACE should not be null"); + let header = unsafe { &*(ace as *const AceHeader) }; + if header.ace_flags & INHERITED_ACE != 0 { + inherited_ace_count += 1; + } + } + + let freed = unsafe { LocalFree(security_descriptor) }; + assert!(freed.is_null(), "security descriptor should be released"); + (control, inherited_ace_count) +} + +#[cfg(windows)] +#[test] +fn score_attachment_publication_inherits_windows_parent_dacl() { + const SE_DACL_PROTECTED: u16 = 0x1000; + + let root = unique_test_dir("score-attachment-windows-acl"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + std::fs::write(&source, b"%PDF-1.7\nwindows inherited acl") + .expect("score fixture should be written"); + + publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect("score publication should succeed with Windows ACL inheritance"); + let destination = root.join(format!("{SCORE_ID}.pdf")); + let (control, inherited_ace_count) = windows_dacl_inheritance(&destination); + + assert_eq!( + control & SE_DACL_PROTECTED, + 0, + "published score must not opt out of parent DACL inheritance" + ); + assert!( + inherited_ace_count > 0, + "published score should retain at least one inherited ACE from the app-owned parent" + ); + let _ = std::fs::remove_dir_all(root); +} From 48e6195cfd3b4574fd30225185c51aa6f29546bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 01:32:18 +0900 Subject: [PATCH 032/166] docs(score): record Windows ACL inheritance acceptance --- .../score-attachment-publication.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 098bc0f7a..2c3965cdb 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -22,7 +22,8 @@ Publication cleanup also retained one Windows gap after the first identity repai - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. - Revalidate `%PDF-` on the bytes actually copied. - Stage with `create_new`. Unix requests `0600` at first visibility rather than creating broad permissions and tightening them later. -- Windows inherits the app-owned scores-directory ACL and denies sharing during the untrusted write. The synchronized stage handle is closed before hard-link publication because pathname mutation is intentionally denied while that handle is live. +- Windows does not synthesize a POSIX-style child ACL. The stage is created inside the app-owned scores directory and inherits that parent DACL. Native acceptance requires the published score DACL to remain unprotected (`SE_DACL_PROTECTED` clear) and to contain at least one ACE marked `INHERITED_ACE` on the Windows Server 2025 runner. +- Windows denies sharing during the untrusted write. The synchronized stage handle is closed before hard-link publication because pathname mutation is intentionally denied while that handle is live. - Publish with a hard link to `.pdf` so an existing attachment is never overwritten. - Return the byte count from the descriptor-bound copy rather than the earlier path-validation size snapshot. @@ -63,7 +64,13 @@ The Windows retention regression acquires the delete handle, renames the opened RED `89f136e312ded829b89f2beafda68ca1c373044c` inserted a hook after Windows stage identity validation but before the old pathname unlink. The hook renames the owned stage and creates a foreign file at the former stage pathname. Exact run `35454589087` behaved as expected: macOS job `105927525260` stayed **SUCCESS** because the regression is Windows-only; Windows Server 2025 job `105927525168` failed in the owned Score Storage unit-test step. This is hosted RED evidence that identity-check-then-pathname-unlink was still redirectable. -The repair keeps the identity-matched stage handle open with DELETE authority and applies `FileDispositionInfo` to that exact object. The same helper is used by retention deletion, avoiding two divergent Windows delete contracts. The regression now requires the foreign replacement to survive while the captured stage object is deleted. +GREEN `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` keeps the identity-matched stage handle open with DELETE authority and applies `FileDispositionInfo` to that exact object. The same helper is used by retention deletion, avoiding two divergent Windows delete contracts. Exact native run `35454808019` passed on macOS job `105928096806` and Windows Server 2025 job `105928096678`. + +### Windows ACL inheritance acceptance + +Commit `579a67d4767d098d0dd608b5678d59e66efe1701` adds native inspection of the published score security descriptor using `GetNamedSecurityInfoW`, `GetSecurityDescriptorControl`, `GetAclInformation`, and `GetAce`. The test does not compare Windows permissions to Unix `0600`; it verifies the chosen Windows contract directly: the child DACL is not protected from parent inheritance and the resulting DACL contains at least one ACE carrying `INHERITED_ACE`. + +Exact `score-storage-native` run `35455174325` passed on macOS job `105929062031` and Windows Server 2025 job `105929062235`, including the Windows ACL regression. This closes the file-level ACL-inheritance evidence gap for the current parent directory model. It does not pre-approve whatever parent-directory ACL #970 eventually establishes; workspace reconciliation still requires a fresh exact-head run. ## Alternatives rejected @@ -81,13 +88,13 @@ Weakening the Windows staging write share mode was also rejected. The write rema **Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink object, or identity mismatch fails closed with payload-safe diagnostics. Unexpected foreign replacements are preserved in the tested Windows object-bound races and Unix pre-`unlinkat` replacement case. -**Privacy.** Unix publication requests `0600` at first visibility. Windows uses actual parent ACL inheritance; no POSIX-equivalence claim is made. +**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL; the native acceptance test verifies that the child is not DACL-protected and contains inherited ACEs rather than asserting POSIX-equivalent permissions. -**Test points.** Core/native tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. +**Test points.** Core/native tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. ## Remaining risk / claim boundary -This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, actual Windows ACL/inheritance acceptance, and Project Persistence #970 workspace reconciliation remain open. +This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, and Project Persistence #970 workspace reconciliation remain open. The Windows file-level inheritance contract is now tested, but a changed workspace ACL after #970 integration requires fresh acceptance rather than evidence transfer. Windows explicit removal and Windows stage cleanup are object-bound to the handles used by `FileDispositionInfo` for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup still retain final pathname/name races unless a stronger platform-specific primitive or storage invariant is adopted. Those risks must remain visible in the threat model rather than being described as race-free. @@ -103,6 +110,12 @@ Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. M Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle +Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules + +Microsoft. (n.d.). *Automatic propagation of inheritable ACEs*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/automatic-propagation-of-inheritable-aces + +Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow + Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html @@ -111,4 +124,4 @@ The Open Group. (2024). *open, openat — open file relative to a directory file The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html -The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html +The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html \ No newline at end of file From 3274560cff4b8ecec5856d6f358e1a5fcea1c212 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 02:02:01 +0900 Subject: [PATCH 033/166] test(score): reproduce final publication identity swap --- apps/desktop/core/src/score_storage.rs | 84 ++++++++++++++++++++------ 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 78734ce46..e5ce295d8 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -423,26 +423,15 @@ pub fn remove_score_pdf_attachment(path: &Path) -> Result<(), String> { remove_score_pdf_attachment_with_hook(path, || {}) } -/// Publish one validated score PDF into the app-owned score workspace. -/// -/// The source is reopened once and copied from that descriptor with a fixed -/// 25 MiB ceiling. The descriptor length is snapshotted before copy; early EOF -/// and an extra byte after the snapshot both fail closed, so truncation or -/// growth cannot silently change the accepted resource. The staging file is -/// created with `create_new`; Unix requests mode `0600` at first visibility, -/// while Windows deliberately inherits the app-owned parent ACL. Publication -/// uses a hard link so an existing `.pdf` is never replaced. -/// -/// Security Notes: errors never include the source path or PDF bytes. Unix -/// cleanup compares device/inode identity captured from the open stage. Windows -/// opens the exact stage object with DELETE authority, verifies its volume plus -/// 128-bit file id against the captured identity, then marks that same handle -/// for deletion so a late pathname replacement cannot redirect cleanup. -pub fn publish_score_pdf_attachment( +fn publish_score_pdf_attachment_with_hook( source: &Path, scores_root: &Path, score_id: &str, -) -> Result { + after_link: F, +) -> Result +where + F: FnOnce(&Path, &Path), +{ if !is_valid_score_id(score_id) { return Err(SCORE_ATTACH_ERROR.to_string()); } @@ -497,6 +486,8 @@ pub fn publish_score_pdf_attachment( return Err(SCORE_ATTACH_ERROR.to_string()); } + after_link(&stage, &destination); + let destination_metadata = fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; if !destination_metadata.is_file() || destination_metadata.len() != written { @@ -510,6 +501,29 @@ pub fn publish_score_pdf_attachment( Ok(written) } +/// Publish one validated score PDF into the app-owned score workspace. +/// +/// The source is reopened once and copied from that descriptor with a fixed +/// 25 MiB ceiling. The descriptor length is snapshotted before copy; early EOF +/// and an extra byte after the snapshot both fail closed, so truncation or +/// growth cannot silently change the accepted resource. The staging file is +/// created with `create_new`; Unix requests mode `0600` at first visibility, +/// while Windows deliberately inherits the app-owned parent ACL. Publication +/// uses a hard link so an existing `.pdf` is never replaced. +/// +/// Security Notes: errors never include the source path or PDF bytes. Unix +/// cleanup compares device/inode identity captured from the open stage. Windows +/// opens the exact stage object with DELETE authority, verifies its volume plus +/// 128-bit file id against the captured identity, then marks that same handle +/// for deletion so a late pathname replacement cannot redirect cleanup. +pub fn publish_score_pdf_attachment( + source: &Path, + scores_root: &Path, + score_id: &str, +) -> Result { + publish_score_pdf_attachment_with_hook(source, scores_root, score_id, |_, _| {}) +} + #[cfg(test)] mod tests { use super::*; @@ -579,6 +593,42 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn publication_rejects_same_length_foreign_destination_replacement() { + const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + + let root = unique_test_dir("score-publication-identity"); + fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + let moved = root.join("owned-published.pdf"); + fs::write(&source, b"%PDF-owned").expect("score fixture should be written"); + + let error = publish_score_pdf_attachment_with_hook( + &source, + &root, + SCORE_ID, + |_, destination| { + fs::rename(destination, &moved) + .expect("published owned link should move before attestation"); + fs::write(destination, b"%PDF-other") + .expect("same-length foreign replacement should be written"); + }, + ) + .expect_err("same-length foreign destination must fail identity attestation"); + + assert_eq!(error, SCORE_ATTACH_ERROR); + assert_eq!( + fs::read(root.join(format!("{SCORE_ID}.pdf"))) + .expect("foreign replacement should remain as evidence"), + b"%PDF-other" + ); + assert_eq!( + fs::read(&moved).expect("owned publication should remain preserved"), + b"%PDF-owned" + ); + let _ = fs::remove_dir_all(root); + } + #[cfg(unix)] #[test] fn unix_delete_preserves_replacement_before_descriptor_relative_unlink() { From 91c240385acdc7ae5dea4a2ebd7c32db0f53953f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 02:03:18 +0900 Subject: [PATCH 034/166] fix(score): attest final attachment object identity --- apps/desktop/core/src/score_storage.rs | 101 +++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index e5ce295d8..7b7d5ebc0 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -201,6 +201,76 @@ fn stage_identity(file: &File) -> Result { }) } +#[cfg(unix)] +fn destination_matches_stage( + path: &Path, + expected: StageIdentity, + written: u64, +) -> Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let entry = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !entry.is_file() + || entry.len() != written + || entry.dev() != expected.device + || entry.ino() != expected.inode + { + return Ok(false); + } + + let file = OpenOptions::new() + .read(true) + .custom_flags(O_NOFOLLOW) + .open(path) + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let opened = file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + Ok(opened.is_file() && opened.len() == written && stage_identity(&file)? == expected) +} + +#[cfg(windows)] +fn destination_matches_stage( + path: &Path, + expected: StageIdentity, + written: u64, +) -> Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + let entry = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !entry.is_file() + || entry.len() != written + || entry.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Ok(false); + } + + let mut options = OpenOptions::new(); + options + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(path) + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let opened = file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + Ok(opened.is_file() + && opened.len() == written + && opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + && stage_identity(&file)? == expected) +} + +#[cfg(all(not(unix), not(windows)))] +fn destination_matches_stage( + _path: &Path, + _expected: StageIdentity, + _written: u64, +) -> Result { + Err(SCORE_ATTACH_ERROR.to_string()) +} + #[cfg(windows)] fn open_windows_delete_handle(path: &Path, error: &str) -> Result { use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; @@ -488,16 +558,27 @@ where after_link(&stage, &destination); - let destination_metadata = - fs::metadata(&destination).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; - if !destination_metadata.is_file() || destination_metadata.len() != written { - // Do not unlink `destination` here: after publication a pathname swap - // could make it foreign. Preserve unexpected evidence and fail closed. + // A same-length regular-file replacement is not the object we staged. + // Attest the pathname to the synchronized stage identity both before and + // after retiring the temporary alias; unexpected replacements are kept as + // evidence rather than unlinked through pathname authority. + if !matches!( + destination_matches_stage(&destination, expected_stage, written), + Ok(true) + ) { let _ = remove_owned_stage(&stage, expected_stage); return Err(SCORE_ATTACH_ERROR.to_string()); } remove_owned_stage(&stage, expected_stage)?; + + if !matches!( + destination_matches_stage(&destination, expected_stage, written), + Ok(true) + ) { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + Ok(written) } @@ -509,13 +590,19 @@ where /// growth cannot silently change the accepted resource. The staging file is /// created with `create_new`; Unix requests mode `0600` at first visibility, /// while Windows deliberately inherits the app-owned parent ACL. Publication -/// uses a hard link so an existing `.pdf` is never replaced. +/// uses a hard link so an existing `.pdf` is never replaced. Before +/// returning attachment authority, the destination is matched to the exact +/// synchronized stage object by device/inode on Unix or volume/file ID on +/// Windows, with a second check after the temporary stage alias is retired. /// /// Security Notes: errors never include the source path or PDF bytes. Unix /// cleanup compares device/inode identity captured from the open stage. Windows /// opens the exact stage object with DELETE authority, verifies its volume plus /// 128-bit file id against the captured identity, then marks that same handle -/// for deletion so a late pathname replacement cannot redirect cleanup. +/// for deletion so a late pathname replacement cannot redirect cleanup. Final +/// destination attestation is identity-based but does not claim immunity from a +/// replacement that occurs after the last attestation and before the caller's +/// later use of the returned attachment. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, From 18ab865e43d5f3c2f10013f3e26609db46270b4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 02:05:32 +0900 Subject: [PATCH 035/166] docs(score): trace final publication identity contract --- .../score-attachment-publication.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 2c3965cdb..d55318587 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -13,6 +13,8 @@ Deletion had a separate authority gap. `remove_score_pdf` first resolved and can Publication cleanup also retained one Windows gap after the first identity repair: cleanup reopened the stage, compared `FILE_ID_INFO`, closed that handle, then called pathname-based `remove_file`. A replacement after the identity check could redirect cleanup to a foreign file. +Final publication attestation had a separate correctness gap. After no-clobber hard-link publication, the implementation checked only that `.pdf` was a regular file with the expected length. A same-length foreign regular file replacing that pathname before the check could therefore be accepted as the attachment even though it was not the synchronized staging object. + ## Publication decision `publish_score_pdf_attachment` is the native Score Storage publication boundary. @@ -25,6 +27,7 @@ Publication cleanup also retained one Windows gap after the first identity repai - Windows does not synthesize a POSIX-style child ACL. The stage is created inside the app-owned scores directory and inherits that parent DACL. Native acceptance requires the published score DACL to remain unprotected (`SE_DACL_PROTECTED` clear) and to contain at least one ACE marked `INHERITED_ACE` on the Windows Server 2025 runner. - Windows denies sharing during the untrusted write. The synchronized stage handle is closed before hard-link publication because pathname mutation is intentionally denied while that handle is live. - Publish with a hard link to `.pdf` so an existing attachment is never overwritten. +- Before returning attachment authority, attest that the destination is the exact synchronized stage object: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. Reject symlink/reparse or non-regular destinations. Repeat the identity check after retiring the temporary stage alias. - Return the byte count from the descriptor-bound copy rather than the earlier path-validation size snapshot. ### Stage cleanup authority @@ -72,30 +75,40 @@ Commit `579a67d4767d098d0dd608b5678d59e66efe1701` adds native inspection of the Exact `score-storage-native` run `35455174325` passed on macOS job `105929062031` and Windows Server 2025 job `105929062235`, including the Windows ACL regression. This closes the file-level ACL-inheritance evidence gap for the current parent directory model. It does not pre-approve whatever parent-directory ACL #970 eventually establishes; workspace reconciliation still requires a fresh exact-head run. +### Final publication object identity + +RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212` inserted a deterministic hook immediately after the hard-link publication. The regression moves the owned published link aside and writes a different same-length regular PDF at `.pdf`. Exact run `35456813143` failed in the owned Score Storage unit-test step on both macOS job `105933454955` and Windows Server 2025 job `105933455061`. This proves that regular-file + length attestation alone could accept a foreign replacement. + +GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` adds platform identity attestation before success. Unix rejects non-regular/symlink destinations, opens with `O_NOFOLLOW`, and requires the destination device/inode to equal the captured synchronized stage. Windows rejects reparse/non-regular destinations, opens with `FILE_FLAG_OPEN_REPARSE_POINT`, and requires the destination volume serial + 128-bit file id to equal the captured stage. The identity is checked again after the temporary stage alias is retired. Exact native run `35456879153` passed on macOS job `105933633903` and Windows Server 2025 job `105933633950`, including owned unit tests and publication/wiring regressions. + ## Alternatives rejected `std::fs::copy` was rejected because it does not express the publication invariants as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting a UUID destination was rejected because correctness must not rely on collision probability when a no-clobber primitive exists. A second pathname `stat` before deletion was rejected because it only moves the race. Windows provides an object-bound delete operation through a handle with DELETE authority, so closing the identity handle and later unlinking the name is unnecessary. For Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`; this narrows authority while retaining an explicit residual POSIX name race. +Length-only final publication checks were rejected because a same-length foreign regular file is not the staged score. Hashing the pathname after publication was also rejected as the primary identity primitive: the hard-link contract already gives an OS object identity, while a path hash would still bind verification to whatever object the name resolves to at that moment and would add a second full PDF read without closing the name-replacement class. The selected contract verifies the OS object identity plus the descriptor-bound byte count. + Weakening the Windows staging write share mode was also rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. ## Security Notes **Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. -**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber attachment. Explicit removal is validated in-root score path → Score Storage OS-object deletion boundary. +**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Explicit removal is validated in-root score path → Score Storage OS-object deletion boundary. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink object, or identity mismatch fails closed with payload-safe diagnostics. Unexpected foreign replacements are preserved in the tested Windows object-bound races and Unix pre-`unlinkat` replacement case. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink object, or identity mismatch fails closed with payload-safe diagnostics. Unexpected foreign replacements are preserved in the tested final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. **Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL; the native acceptance test verifies that the child is not DACL-protected and contains inherited ACEs rather than asserting POSIX-equivalent permissions. -**Test points.** Core/native tests cover valid publication, no-clobber publication, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. +**Test points.** Core/native tests cover valid publication, no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. ## Remaining risk / claim boundary This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, and Project Persistence #970 workspace reconciliation remain open. The Windows file-level inheritance contract is now tested, but a changed workspace ACL after #970 integration requires fresh acceptance rather than evidence transfer. +Final publication now requires the pathname to resolve to the exact synchronized stage object at the final attestation. It does not make a pathname immutable after the function returns: a later same-user/local process replacement before a caller subsequently opens the score is outside this publication boundary and must be handled by the read/path-authority contracts rather than described as permanently race-free. + Windows explicit removal and Windows stage cleanup are object-bound to the handles used by `FileDispositionInfo` for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup still retain final pathname/name races unless a stronger platform-specific primitive or storage invariant is adopted. Those risks must remain visible in the threat model rather than being described as race-free. The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 remains the owner of reads and must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. From 9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:00:06 +0900 Subject: [PATCH 036/166] test(score): distinguish absent attachment removal --- .../tests/score_pdf_retention_resolution.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_retention_resolution.rs diff --git a/apps/desktop/core/tests/score_pdf_retention_resolution.rs b/apps/desktop/core/tests/score_pdf_retention_resolution.rs new file mode 100644 index 000000000..bd96563c4 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_retention_resolution.rs @@ -0,0 +1,83 @@ +use bandscope_desktop_core::resolve_score_pdf_for_removal; +use std::fs; + +fn unique_temp_dir(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "bandscope-score-retention-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should follow UNIX epoch") + .as_nanos() + )); + fs::create_dir_all(&root).expect("retention test root should be created"); + root +} + +#[test] +fn removal_resolution_returns_none_only_for_an_absent_entry() { + let scores_root = unique_temp_dir("missing"); + let result = resolve_score_pdf_for_removal(&scores_root, "score-missing") + .expect("genuinely absent score should be an idempotent removal"); + assert!(result.is_none()); + fs::remove_dir_all(scores_root).expect("retention test root should be removed"); +} + +#[test] +fn removal_resolution_returns_the_existing_regular_score() { + let scores_root = unique_temp_dir("regular"); + let score_id = "score-regular"; + let path = scores_root.join(format!("{score_id}.pdf")); + fs::write(&path, b"%PDF-retention").expect("score fixture should be written"); + + let resolved = resolve_score_pdf_for_removal(&scores_root, score_id) + .expect("regular score should resolve") + .expect("existing score should not be reported absent"); + assert_eq!(resolved, path.canonicalize().expect("score path should canonicalize")); + + fs::remove_dir_all(scores_root).expect("retention test root should be removed"); +} + +#[test] +fn removal_resolution_rejects_a_directory_instead_of_reporting_absent() { + let scores_root = unique_temp_dir("directory"); + let score_id = "score-directory"; + fs::create_dir(scores_root.join(format!("{score_id}.pdf"))) + .expect("directory fixture should be created"); + + assert!(resolve_score_pdf_for_removal(&scores_root, score_id).is_err()); + fs::remove_dir_all(scores_root).expect("retention test root should be removed"); +} + +#[cfg(unix)] +#[test] +fn removal_resolution_rejects_a_symlink_instead_of_reporting_absent() { + use std::os::unix::fs::symlink; + + let scores_root = unique_temp_dir("symlink"); + let target = scores_root.join("foreign.pdf"); + fs::write(&target, b"%PDF-foreign").expect("foreign score fixture should be written"); + let score_id = "score-symlink"; + symlink(&target, scores_root.join(format!("{score_id}.pdf"))) + .expect("symlink fixture should be created"); + + assert!(resolve_score_pdf_for_removal(&scores_root, score_id).is_err()); + fs::remove_dir_all(scores_root).expect("retention test root should be removed"); +} + +const TAURI_MAIN: &str = include_str!("../../src-tauri/src/main.rs"); + +#[test] +fn tauri_removal_preserves_unsafe_resolution_errors() { + let remove_start = TAURI_MAIN + .find("fn remove_score_pdf(") + .expect("remove_score_pdf command should exist"); + let main_start = TAURI_MAIN[remove_start..] + .find("fn main()") + .map(|offset| remove_start + offset) + .expect("main should follow remove_score_pdf command"); + let remove_source = &TAURI_MAIN[remove_start..main_start]; + + assert!(remove_source.contains("resolve_score_pdf_for_removal(")); + assert!(!remove_source.contains("Err(_) => return Ok(false)")); +} From 4e1c019329fe5e18c10ba5206c0c617fd2d0fb3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:01:53 +0900 Subject: [PATCH 037/166] fix(score): classify removal absence without masking unsafe states --- apps/desktop/core/src/score_retention.rs | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/desktop/core/src/score_retention.rs diff --git a/apps/desktop/core/src/score_retention.rs b/apps/desktop/core/src/score_retention.rs new file mode 100644 index 000000000..f99a58a98 --- /dev/null +++ b/apps/desktop/core/src/score_retention.rs @@ -0,0 +1,39 @@ +use crate::runtime_core::{is_valid_score_id, resolve_existing_score_pdf}; +use std::{fs, io::ErrorKind, path::{Path, PathBuf}}; + +const SCORE_REMOVE_ERROR: &str = "Could not remove the score PDF."; + +/// Resolve one score attachment for idempotent removal without collapsing +/// unsafe or indeterminate filesystem states into "already absent". +/// +/// `Ok(None)` is returned only when `symlink_metadata` observes that the exact +/// app-owned `.pdf` directory entry does not exist. Once an entry is +/// observed, the existing score-path authority validates the regular-file, +/// symlink, canonicalization, and containment contract. Any validation or I/O +/// failure after that point is an error, including a concurrent disappearance; +/// callers must not erase buyer metadata on those failures. +/// +/// Security Notes: the score id is allowlisted before the path join. Errors do +/// not expose the local path or score bytes. There is an unavoidable race after +/// an observed `NotFound`: another local actor can create the name before the +/// caller updates metadata, so this contract proves an absence observation, not +/// durable pathname non-existence. +pub fn resolve_score_pdf_for_removal( + scores_root: &Path, + score_id: &str, +) -> Result, String> { + if !is_valid_score_id(score_id) { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + + let candidate = scores_root.join(format!("{score_id}.pdf")); + match fs::symlink_metadata(&candidate) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(SCORE_REMOVE_ERROR.to_string()), + } + + resolve_existing_score_pdf(scores_root, score_id) + .map(Some) + .map_err(|_| SCORE_REMOVE_ERROR.to_string()) +} From 551f11c6a89a205a432026ec1dc5a43b70d51217 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:01:58 +0900 Subject: [PATCH 038/166] fix(score): expose removal resolution authority --- apps/desktop/core/src/root.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 60cee49be..6e212307c 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -8,8 +8,10 @@ #[path = "lib.rs"] mod runtime_core; mod score_pdf; +mod score_retention; mod score_storage; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; +pub use score_retention::resolve_score_pdf_for_removal; pub use score_storage::{publish_score_pdf_attachment, remove_score_pdf_attachment}; From a989e0edd1270244d94156a11b7f4d23cc693be7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:02:17 +0900 Subject: [PATCH 039/166] test(score): use valid score identities in retention regressions --- .../desktop/core/tests/score_pdf_retention_resolution.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_retention_resolution.rs b/apps/desktop/core/tests/score_pdf_retention_resolution.rs index bd96563c4..95a5879b7 100644 --- a/apps/desktop/core/tests/score_pdf_retention_resolution.rs +++ b/apps/desktop/core/tests/score_pdf_retention_resolution.rs @@ -17,7 +17,8 @@ fn unique_temp_dir(label: &str) -> std::path::PathBuf { #[test] fn removal_resolution_returns_none_only_for_an_absent_entry() { let scores_root = unique_temp_dir("missing"); - let result = resolve_score_pdf_for_removal(&scores_root, "score-missing") + let score_id = "11111111-1111-4111-8111-111111111111"; + let result = resolve_score_pdf_for_removal(&scores_root, score_id) .expect("genuinely absent score should be an idempotent removal"); assert!(result.is_none()); fs::remove_dir_all(scores_root).expect("retention test root should be removed"); @@ -26,7 +27,7 @@ fn removal_resolution_returns_none_only_for_an_absent_entry() { #[test] fn removal_resolution_returns_the_existing_regular_score() { let scores_root = unique_temp_dir("regular"); - let score_id = "score-regular"; + let score_id = "22222222-2222-4222-8222-222222222222"; let path = scores_root.join(format!("{score_id}.pdf")); fs::write(&path, b"%PDF-retention").expect("score fixture should be written"); @@ -41,7 +42,7 @@ fn removal_resolution_returns_the_existing_regular_score() { #[test] fn removal_resolution_rejects_a_directory_instead_of_reporting_absent() { let scores_root = unique_temp_dir("directory"); - let score_id = "score-directory"; + let score_id = "33333333-3333-4333-8333-333333333333"; fs::create_dir(scores_root.join(format!("{score_id}.pdf"))) .expect("directory fixture should be created"); @@ -57,7 +58,7 @@ fn removal_resolution_rejects_a_symlink_instead_of_reporting_absent() { let scores_root = unique_temp_dir("symlink"); let target = scores_root.join("foreign.pdf"); fs::write(&target, b"%PDF-foreign").expect("foreign score fixture should be written"); - let score_id = "score-symlink"; + let score_id = "44444444-4444-4444-8444-444444444444"; symlink(&target, scores_root.join(format!("{score_id}.pdf"))) .expect("symlink fixture should be created"); From 3c7e82a91a1ea8c8c64ac866f444755b46ffa935 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:03:29 +0900 Subject: [PATCH 040/166] fix(score): preserve unsafe removal resolution errors --- apps/desktop/src-tauri/src/main.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index f6a7ccbcb..d04003f2f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -841,10 +841,11 @@ fn read_score_pdf( } /// Security Notes: same id validation and traversal guard as `read_score_pdf`. -/// Score Storage owns the final deletion authority: Windows marks the opened -/// file object for deletion by handle; Unix pins the parent directory and -/// revalidates device/inode before descriptor-relative `unlinkat`. -/// Returns `false` when the score does not exist (idempotent removal). +/// Score Storage distinguishes a genuinely absent directory entry from unsafe +/// or indeterminate resolution before invoking object-bound deletion. Only the +/// observed-absent case returns `false`; symlink, non-regular, containment, and +/// I/O failures remain errors so the UI cannot silently discard attachment +/// metadata while storage is still present or unverified. #[tauri::command] fn remove_score_pdf( project_id: String, @@ -858,9 +859,8 @@ fn remove_score_pdf( return Err("Invalid score id.".to_string()); } let scores_root = scores_root_for_project(&app, &project_id)?; - let path = match resolve_existing_score_pdf(&scores_root, &score_id) { - Ok(path) => path, - Err(_) => return Ok(false), + let Some(path) = resolve_score_pdf_for_removal(&scores_root, &score_id)? else { + return Ok(false); }; remove_score_pdf_attachment(&path)?; Ok(true) @@ -882,4 +882,4 @@ fn main() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} \ No newline at end of file +} From 7fab4fd18936fc526e30ef8f8e90d132476fe5f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:04:29 +0900 Subject: [PATCH 041/166] ci(score): include retention authority regressions --- .github/workflows/score-storage-native.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index a1f30f459..a24c5c0f9 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -10,6 +10,7 @@ on: - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -24,6 +25,7 @@ on: - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -59,9 +61,10 @@ jobs: cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --lib score_storage::tests:: - - name: Run Score Storage publication and wiring regressions + - name: Run Score Storage publication, retention, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --test score_pdf_attachment_publication + --test score_pdf_retention_resolution --test score_pdf_attachment_wiring From f98e5a48918cf41743f7f5fb6c853c7fb98916f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:05:30 +0900 Subject: [PATCH 042/166] docs(score): trace removal absence classification --- .../score-attachment-publication.md | 107 ++++++++++-------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index d55318587..24d82cf28 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -3,17 +3,15 @@ Issue: #1239 Canonical owner: Score Storage / Score Attachment -## Problem +## Problem and owner boundary -The desktop attachment command originally admitted a PDF and copied it into `/scores` with `std::fs::copy`. That left first-visible permissions, source mutation during copy, no-clobber publication, partial-copy cleanup, and retention semantics implicit. +The desktop attachment command originally admitted a PDF and copied it into `/scores` with `std::fs::copy`. That left first-visible permissions, source mutation during copy, no-clobber publication, partial-copy cleanup, final-object identity, and retention semantics implicit. -The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This lane owns score attachment publication and explicit attachment removal only. +The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This lane owns score attachment publication and explicit attachment removal/retention only. -Deletion had a separate authority gap. `remove_score_pdf` first resolved and canonicalized `.pdf`, then later called pathname-based `std::fs::remove_file`. A local replacement between validation and deletion could make those operations refer to different file objects. +Deletion had two separate authority problems. First, the product resolved and canonicalized `.pdf` and later removed a pathname, allowing validation and deletion to refer to different objects. Second, the Tauri command treated every `resolve_existing_score_pdf` error as `Ok(false)`. That collapsed a genuinely absent entry together with symlink/non-regular, containment, canonicalization, permission, and other indeterminate states. The UI could therefore treat unsafe storage as "already gone" and remove attachment metadata. -Publication cleanup also retained one Windows gap after the first identity repair: cleanup reopened the stage, compared `FILE_ID_INFO`, closed that handle, then called pathname-based `remove_file`. A replacement after the identity check could redirect cleanup to a foreign file. - -Final publication attestation had a separate correctness gap. After no-clobber hard-link publication, the implementation checked only that `.pdf` was a regular file with the expected length. A same-length foreign regular file replacing that pathname before the check could therefore be accepted as the attachment even though it was not the synchronized staging object. +Publication also had two independent identity gaps. Windows stage cleanup initially compared `FILE_ID_INFO`, closed that handle, and then unlinked a pathname, so a late replacement could redirect cleanup. Final publication initially accepted any regular destination of the expected length, so a same-length foreign file could be accepted as the score. ## Publication decision @@ -24,94 +22,103 @@ Final publication attestation had a separate correctness gap. After no-clobber h - Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. - Revalidate `%PDF-` on the bytes actually copied. - Stage with `create_new`. Unix requests `0600` at first visibility rather than creating broad permissions and tightening them later. -- Windows does not synthesize a POSIX-style child ACL. The stage is created inside the app-owned scores directory and inherits that parent DACL. Native acceptance requires the published score DACL to remain unprotected (`SE_DACL_PROTECTED` clear) and to contain at least one ACE marked `INHERITED_ACE` on the Windows Server 2025 runner. -- Windows denies sharing during the untrusted write. The synchronized stage handle is closed before hard-link publication because pathname mutation is intentionally denied while that handle is live. -- Publish with a hard link to `.pdf` so an existing attachment is never overwritten. -- Before returning attachment authority, attest that the destination is the exact synchronized stage object: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. Reject symlink/reparse or non-regular destinations. Repeat the identity check after retiring the temporary stage alias. -- Return the byte count from the descriptor-bound copy rather than the earlier path-validation size snapshot. +- Windows inherits the app-owned scores-directory DACL. Native acceptance requires the child DACL to remain unprotected (`SE_DACL_PROTECTED` clear) and contain at least one `INHERITED_ACE`; this is the Windows contract rather than a POSIX `0600` analogy. +- Windows denies sharing during the untrusted write. The synchronized stage handle closes before hard-link publication because pathname mutation is intentionally denied while that handle is live. +- Publish with a hard link to `.pdf`; an existing score id is never overwritten. +- Before returning attachment authority, attest that the destination is the exact synchronized stage object: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. Reject symlink/reparse and non-regular destinations. Repeat the identity check after retiring the temporary stage alias. +- Return the byte count from the descriptor-bound copy rather than an earlier path-validation size snapshot. ### Stage cleanup authority -On Unix, stage identity is device/inode from the owned stage. Cleanup compares that identity before pathname unlink. This still has a documented final identity-check-to-unlink race. +On Unix, stage identity is device/inode from the owned stage. Cleanup compares identity before pathname unlink. A final identity-check-to-unlink name race remains explicit. -On Windows, cleanup now uses the same object-bound deletion model as explicit retention removal. It opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_FLAG_OPEN_REPARSE_POINT`, and normal read/write/delete sharing; rejects reparse/non-regular objects; verifies `FILE_ID_INFO` volume serial + 128-bit file id against the identity captured from the original staging handle; then calls `SetFileInformationByHandle(FileDispositionInfo)` on that same open handle. A later pathname replacement cannot redirect cleanup to the replacement. +On Windows, cleanup opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_FLAG_OPEN_REPARSE_POINT`, and read/write/delete sharing; rejects reparse/non-regular objects; verifies volume serial + 128-bit file id against the staging identity; then calls `SetFileInformationByHandle(FileDispositionInfo)` on that same handle. A foreign replacement cannot redirect deletion to itself. ## Retention/delete decision -`remove_score_pdf_attachment` owns the final score-file deletion authority after the caller resolves the id inside the app-owned score root. - -- Windows opens the exact file object with `DELETE | FILE_READ_ATTRIBUTES`, rejects reparse objects, and marks that handle with `SetFileInformationByHandle(FileDispositionInfo)`. Microsoft documents `FILE_DISPOSITION_INFO.DeleteFile` as the request to delete the file and requires DELETE authority for this information class. -- Unix pins the parent directory, opens the score basename with `openat(..., O_NOFOLLOW)`, captures device/inode, then reopens the basename under the same parent descriptor immediately before `unlinkat`. -- A Unix replacement observed before the second identity check is preserved and deletion fails closed. -- Portable POSIX `unlinkat` removes a directory entry rather than an arbitrary already-open object, so the final identity-check-to-`unlinkat` basename interval remains a documented residual race. Pinning the parent removes ancestor substitution but does not erase that last name race. +`remove_score_pdf_attachment` owns final score-file deletion after Score Storage has resolved an authorized attachment. -## Hosted findings and repairs +- Windows opens the exact non-reparse object with `DELETE | FILE_READ_ATTRIBUTES` and marks that handle with `FileDispositionInfo`. +- Unix pins the parent directory, opens the score basename with `openat(..., O_NOFOLLOW)`, captures device/inode, reopens under the same parent descriptor immediately before `unlinkat`, and fails closed if identity changed. +- Portable POSIX `unlinkat` removes a directory entry rather than an arbitrary already-open object. Parent pinning removes ancestor substitution but not the final identity-check-to-`unlinkat` basename race. -### Publication and first Windows cleanup repair +`resolve_score_pdf_for_removal` now owns the missing-versus-unsafe classification before deletion: -Initial publication RED `f8ba40d10458ed6b7b3b0e9d4d8f79ec866c50df` added the publication contract before production code. That source-level RED was superseded before a hosted terminal compiler verdict, so no hosted claim is made for it. +- validate the score id before joining a local name; +- inspect the exact `.pdf` directory entry with `symlink_metadata`; +- return `Ok(None)` only when that lookup itself reports `ErrorKind::NotFound`; +- once an entry is observed, delegate to the existing `resolve_existing_score_pdf` authority and convert any symlink, non-regular, canonicalization, containment, permission, concurrent-disappearance, or other validation failure into a removal error; +- return `Some(path)` only for an authorized existing score. -Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` separated a Windows platform contract from workflow noise. macOS passed the owned Score Storage regressions. Windows passed the owned unit tests but failed publication because a `share_mode(0)` stage handle was still live while hard-link/unlink needed pathname mutation. The repair keeps exclusive sharing during untrusted write, captures stage identity, synchronizes, then closes the handle before publication. +The Tauri command therefore returns `false` only for an observed absent directory entry. Unsafe or indeterminate storage remains an error, so buyer metadata is not silently discarded while the file is still present or unverified. This is an absence observation, not a durable non-existence guarantee: another same-user/local actor can create the pathname after `NotFound` and before metadata changes. -A second Windows cleanup defect remained: post-close cleanup accepted any regular file at the stage pathname after its earlier identity assumptions. RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the stage with a foreign regular file. Run `35452152802`, Windows job `105921067591`, failed the dedicated regression; macOS job `105921067494` stayed green. GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` switched the comparison identity to `GetFileInformationByHandleEx(FileIdInfo)`, using volume serial plus 128-bit file id. +## Hosted findings and repairs -### Product deletion authority +### Publication and Windows cleanup -RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` added a Tauri wiring contract requiring `remove_score_pdf` to use a Score Storage deletion boundary rather than direct `std::fs::remove_file`. Exact run `35453934315` failed on both native lanes after owned unit tests passed: macOS job `105925792583` and Windows Server 2025 job `105925792679` failed the publication/wiring regression step. +Initial publication RED `f8ba40d10458ed6b7b3b0e9d4d8f79ec866c50df` established the publication contract before production code. That source-level RED was superseded before a terminal hosted compiler verdict, so no hosted claim is made for it. -GREEN `09610fb26127408f818a754a42b75a083e0e5045` routed the command through `remove_score_pdf_attachment`. Run `35454259017` was terminal-success on exact current source at that point: macOS job `105926649166` and Windows job `105926649120` both passed owned unit and publication/wiring regressions. +Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` isolated a Windows sharing contract: macOS passed, while Windows publication failed because a `share_mode(0)` stage handle was still live during hard-link/unlink. The repair keeps exclusive sharing during untrusted write, captures identity, synchronizes, and only then closes before publication. -The Windows retention regression acquires the delete handle, renames the opened score, creates a foreign file at the old pathname, then marks the open handle for deletion. The replacement survives and only the originally opened object is deleted. The Unix counterpart replaces the basename before the second descriptor-relative identity check and requires fail-closed preservation of both files. +RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the Windows stage pathname with a foreign regular file. Run `35452152802`, Windows job `105921067591`, failed that regression while macOS job `105921067494` remained green. GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` switched comparison to `GetFileInformationByHandleEx(FileIdInfo)`. -### Windows stage cleanup final-name race +RED `89f136e312ded829b89f2beafda68ca1c373044c` then replaced the stage after Windows identity validation but before the old pathname unlink. Exact run `35454589087` kept macOS job `105927525260` green and failed Windows job `105927525168`. GREEN `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` keeps the identity-matched delete handle open and applies `FileDispositionInfo`; run `35454808019` passed macOS `105928096806` and Windows `105928096678`. -RED `89f136e312ded829b89f2beafda68ca1c373044c` inserted a hook after Windows stage identity validation but before the old pathname unlink. The hook renames the owned stage and creates a foreign file at the former stage pathname. Exact run `35454589087` behaved as expected: macOS job `105927525260` stayed **SUCCESS** because the regression is Windows-only; Windows Server 2025 job `105927525168` failed in the owned Score Storage unit-test step. This is hosted RED evidence that identity-check-then-pathname-unlink was still redirectable. +### Product deletion authority -GREEN `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` keeps the identity-matched stage handle open with DELETE authority and applies `FileDispositionInfo` to that exact object. The same helper is used by retention deletion, avoiding two divergent Windows delete contracts. Exact native run `35454808019` passed on macOS job `105928096806` and Windows Server 2025 job `105928096678`. +RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` required the Tauri removal command to use Score Storage deletion rather than direct `std::fs::remove_file`. Exact run `35453934315` failed the wiring regression on macOS `105925792583` and Windows Server 2025 `105925792679`. GREEN `09610fb26127408f818a754a42b75a083e0e5045` routed removal through `remove_score_pdf_attachment`; run `35454259017` passed macOS `105926649166` and Windows `105926649120`. -### Windows ACL inheritance acceptance +The Windows replacement regression acquires the delete handle, renames the opened score, creates a foreign file at the former pathname, then marks only the open handle for deletion. The replacement survives. The Unix counterpart replaces the basename before the second descriptor-relative identity check and requires fail-closed preservation. -Commit `579a67d4767d098d0dd608b5678d59e66efe1701` adds native inspection of the published score security descriptor using `GetNamedSecurityInfoW`, `GetSecurityDescriptorControl`, `GetAclInformation`, and `GetAce`. The test does not compare Windows permissions to Unix `0600`; it verifies the chosen Windows contract directly: the child DACL is not protected from parent inheritance and the resulting DACL contains at least one ACE carrying `INHERITED_ACE`. +### Windows ACL inheritance -Exact `score-storage-native` run `35455174325` passed on macOS job `105929062031` and Windows Server 2025 job `105929062235`, including the Windows ACL regression. This closes the file-level ACL-inheritance evidence gap for the current parent directory model. It does not pre-approve whatever parent-directory ACL #970 eventually establishes; workspace reconciliation still requires a fresh exact-head run. +Commit `579a67d4767d098d0dd608b5678d59e66efe1701` inspects the published child security descriptor using `GetNamedSecurityInfoW`, `GetSecurityDescriptorControl`, `GetAclInformation`, and `GetAce`. Exact run `35455174325` passed macOS job `105929062031` and Windows Server 2025 job `105929062235`, proving the selected child-inheritance contract for the current parent directory model. A changed workspace ACL after #970 integration requires fresh acceptance; this evidence is not transferable. ### Final publication object identity -RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212` inserted a deterministic hook immediately after the hard-link publication. The regression moves the owned published link aside and writes a different same-length regular PDF at `.pdf`. Exact run `35456813143` failed in the owned Score Storage unit-test step on both macOS job `105933454955` and Windows Server 2025 job `105933455061`. This proves that regular-file + length attestation alone could accept a foreign replacement. +RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212` moved the owned hard link aside and wrote a different same-length regular PDF at `.pdf`. Exact run `35456813143` failed the owned unit-test step on macOS `105933454955` and Windows Server 2025 `105933455061`, proving regular-file + length attestation insufficient. + +GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added platform object-identity attestation. Exact native run `35456879153` passed macOS `105933633903` and Windows `105933633950`. + +### Missing versus unsafe removal resolution -GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` adds platform identity attestation before success. Unix rejects non-regular/symlink destinations, opens with `O_NOFOLLOW`, and requires the destination device/inode to equal the captured synchronized stage. Windows rejects reparse/non-regular destinations, opens with `FILE_FLAG_OPEN_REPARSE_POINT`, and requires the destination volume serial + 128-bit file id to equal the captured stage. The identity is checked again after the temporary stage alias is retired. Exact native run `35456879153` passed on macOS job `105933633903` and Windows Server 2025 job `105933633950`, including owned unit tests and publication/wiring regressions. +RED contract commit `9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951` introduced `score_pdf_retention_resolution` with four product states: genuinely absent entry, normal regular score, directory masquerading as a score, and Unix symlink replacement, plus a Tauri wiring assertion forbidding blanket `Err(_) => Ok(false)` handling. The then-current workflow did not execute the new retention test target, and run `35459793839` therefore completed successfully instead of providing a valid hosted RED. That workflow omission is itself repaired rather than misreported as RED evidence. + +The causal repair adds `resolve_score_pdf_for_removal`, exports it through the desktop core, and changes the Tauri command so only an observed `NotFound` returns `false`; unsafe or indeterminate resolution propagates an error. `score-storage-native` now explicitly runs `score_pdf_retention_resolution` and its path filter includes `score_retention.rs`, so future changes to this authority cannot silently bypass the owner lane. ## Alternatives rejected -`std::fs::copy` was rejected because it does not express the publication invariants as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting a UUID destination was rejected because correctness must not rely on collision probability when a no-clobber primitive exists. +`std::fs::copy` was rejected because it does not express publication invariants as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting a UUID destination was rejected because correctness must not rely on collision probability when no-clobber publication exists. + +A second pathname `stat` before deletion was rejected because it only moves the race. Windows has object-bound deletion through a handle with DELETE authority. On Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`. -A second pathname `stat` before deletion was rejected because it only moves the race. Windows provides an object-bound delete operation through a handle with DELETE authority, so closing the identity handle and later unlinking the name is unnecessary. For Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`; this narrows authority while retaining an explicit residual POSIX name race. +Treating all resolver failures as absence was rejected because filesystem corruption, permission failure, symlink/reparse substitution, containment failure, and concurrent mutation are not evidence that the attachment is gone. Error-message string matching was also rejected: the resolver intentionally uses payload-safe generic messages and strings are not a stable domain discriminator. The selected contract performs the narrow `NotFound` classification at the exact directory-entry lookup and preserves every later failure. -Length-only final publication checks were rejected because a same-length foreign regular file is not the staged score. Hashing the pathname after publication was also rejected as the primary identity primitive: the hard-link contract already gives an OS object identity, while a path hash would still bind verification to whatever object the name resolves to at that moment and would add a second full PDF read without closing the name-replacement class. The selected contract verifies the OS object identity plus the descriptor-bound byte count. +Length-only final publication checks were rejected because a same-length foreign regular file is not the staged score. Path hashing was rejected as the primary identity primitive because it still binds verification to whichever object the pathname resolves to and requires a second full PDF read. The selected contract uses OS object identity plus the descriptor-bound byte count. -Weakening the Windows staging write share mode was also rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. +Weakening Windows staging share mode was rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. ## Security Notes **Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. -**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Explicit removal is validated in-root score path → Score Storage OS-object deletion boundary. +**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → exact directory-entry absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink object, or identity mismatch fails closed with payload-safe diagnostics. Unexpected foreign replacements are preserved in the tested final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink/non-regular object, identity mismatch, unsafe resolution, and indeterminate I/O fail closed with path/payload-safe diagnostics. Foreign replacements are preserved in the covered final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. -**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL; the native acceptance test verifies that the child is not DACL-protected and contains inherited ACEs rather than asserting POSIX-equivalent permissions. +**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL and tests that inheritance directly rather than asserting POSIX equivalence. -**Test points.** Core/native tests cover valid publication, no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, descriptor growth/truncation, wrong magic, Tauri call-site wiring, Windows post-close stage replacement, Windows stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, and Unix retention replacement before the second descriptor-relative identity check. +**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, and removal classification for absent/regular/directory/symlink states. ## Remaining risk / claim boundary -This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, and Project Persistence #970 workspace reconciliation remain open. The Windows file-level inheritance contract is now tested, but a changed workspace ACL after #970 integration requires fresh acceptance rather than evidence transfer. +This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, cancellation/orphan acceptance, restart/readback, and #970 workspace reconciliation remain open. The Windows file-level inheritance contract must be re-run if #970 changes parent-directory ACL authority. -Final publication now requires the pathname to resolve to the exact synchronized stage object at the final attestation. It does not make a pathname immutable after the function returns: a later same-user/local process replacement before a caller subsequently opens the score is outside this publication boundary and must be handled by the read/path-authority contracts rather than described as permanently race-free. +A successful final publication attests that the pathname resolves to the synchronized stage object at that instant; it does not make the name immutable afterward. A later same-user/local replacement belongs to read/path authority. -Windows explicit removal and Windows stage cleanup are object-bound to the handles used by `FileDispositionInfo` for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup still retain final pathname/name races unless a stronger platform-specific primitive or storage invariant is adopted. Those risks must remain visible in the threat model rather than being described as race-free. +Windows explicit removal and stage cleanup are object-bound for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup retain final basename races unless a stronger platform primitive or storage invariant is adopted. `Ok(None)` from retention resolution is likewise an observed absence, not an atomic reservation preventing subsequent creation. -The implementation is stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 remains the owner of reads and must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. +The implementation remains stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. ## References @@ -137,4 +144,4 @@ The Open Group. (2024). *open, openat — open file relative to a directory file The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html -The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html \ No newline at end of file +The Rust Project Developers. (2026). *OpenOptionsExt — std::os::unix::fs*. Rust documentation. https://doc.rust-lang.org/std/os/unix/fs/trait.OpenOptionsExt.html From 22749b83d9c7e6f7e3c0b5986af5802427e98d2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:06:02 +0900 Subject: [PATCH 043/166] test(score): require an existing retention workspace --- .../core/tests/score_pdf_retention_resolution.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_retention_resolution.rs b/apps/desktop/core/tests/score_pdf_retention_resolution.rs index 95a5879b7..57c410f4b 100644 --- a/apps/desktop/core/tests/score_pdf_retention_resolution.rs +++ b/apps/desktop/core/tests/score_pdf_retention_resolution.rs @@ -24,6 +24,16 @@ fn removal_resolution_returns_none_only_for_an_absent_entry() { fs::remove_dir_all(scores_root).expect("retention test root should be removed"); } +#[test] +fn removal_resolution_rejects_a_missing_scores_root() { + let parent = unique_temp_dir("missing-root"); + let scores_root = parent.join("scores-not-created"); + let score_id = "55555555-5555-4555-8555-555555555555"; + + assert!(resolve_score_pdf_for_removal(&scores_root, score_id).is_err()); + fs::remove_dir_all(parent).expect("retention test parent should be removed"); +} + #[test] fn removal_resolution_returns_the_existing_regular_score() { let scores_root = unique_temp_dir("regular"); From 6444f21a158a877ae402fd665469d908f8afa3a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:07:31 +0900 Subject: [PATCH 044/166] fix(score): require retention workspace before absence --- apps/desktop/core/src/score_retention.rs | 28 +++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/desktop/core/src/score_retention.rs b/apps/desktop/core/src/score_retention.rs index f99a58a98..d930ce10c 100644 --- a/apps/desktop/core/src/score_retention.rs +++ b/apps/desktop/core/src/score_retention.rs @@ -1,23 +1,29 @@ use crate::runtime_core::{is_valid_score_id, resolve_existing_score_pdf}; -use std::{fs, io::ErrorKind, path::{Path, PathBuf}}; +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; const SCORE_REMOVE_ERROR: &str = "Could not remove the score PDF."; /// Resolve one score attachment for idempotent removal without collapsing /// unsafe or indeterminate filesystem states into "already absent". /// -/// `Ok(None)` is returned only when `symlink_metadata` observes that the exact -/// app-owned `.pdf` directory entry does not exist. Once an entry is +/// `Ok(None)` is returned only when a validated, existing score workspace lacks +/// the exact app-owned `.pdf` directory entry. Once an entry is /// observed, the existing score-path authority validates the regular-file, /// symlink, canonicalization, and containment contract. Any validation or I/O /// failure after that point is an error, including a concurrent disappearance; /// callers must not erase buyer metadata on those failures. /// -/// Security Notes: the score id is allowlisted before the path join. Errors do -/// not expose the local path or score bytes. There is an unavoidable race after -/// an observed `NotFound`: another local actor can create the name before the -/// caller updates metadata, so this contract proves an absence observation, not -/// durable pathname non-existence. +/// Security Notes: the score id is allowlisted before the path join. This helper +/// verifies that the supplied score workspace itself exists as a directory before +/// interpreting child `NotFound`; broader app-owned workspace/link authority stays +/// with Project Persistence. Errors do not expose the local path or score bytes. +/// There is an unavoidable race after an observed child `NotFound`: another local +/// actor can create the name before the caller updates metadata, so this contract +/// proves an absence observation, not durable pathname non-existence. pub fn resolve_score_pdf_for_removal( scores_root: &Path, score_id: &str, @@ -26,6 +32,12 @@ pub fn resolve_score_pdf_for_removal( return Err(SCORE_REMOVE_ERROR.to_string()); } + let root_metadata = fs::symlink_metadata(scores_root) + .map_err(|_| SCORE_REMOVE_ERROR.to_string())?; + if !root_metadata.is_dir() { + return Err(SCORE_REMOVE_ERROR.to_string()); + } + let candidate = scores_root.join(format!("{score_id}.pdf")); match fs::symlink_metadata(&candidate) { Ok(_) => {} From 36325246e060dae612eccad8e619d8e2b5b494c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 03:09:42 +0900 Subject: [PATCH 045/166] docs(score): trace retention resolution RED to GREEN --- .../score-attachment-publication.md | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 24d82cf28..143251d25 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -42,15 +42,16 @@ On Windows, cleanup opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_ - Unix pins the parent directory, opens the score basename with `openat(..., O_NOFOLLOW)`, captures device/inode, reopens under the same parent descriptor immediately before `unlinkat`, and fails closed if identity changed. - Portable POSIX `unlinkat` removes a directory entry rather than an arbitrary already-open object. Parent pinning removes ancestor substitution but not the final identity-check-to-`unlinkat` basename race. -`resolve_score_pdf_for_removal` now owns the missing-versus-unsafe classification before deletion: +`resolve_score_pdf_for_removal` owns the missing-versus-unsafe classification before deletion: - validate the score id before joining a local name; +- require the supplied scores workspace itself to exist as a directory before interpreting any child `NotFound`; broader app-owned workspace/link authority remains Project Persistence #970; - inspect the exact `.pdf` directory entry with `symlink_metadata`; -- return `Ok(None)` only when that lookup itself reports `ErrorKind::NotFound`; +- return `Ok(None)` only when that child lookup reports `ErrorKind::NotFound` under the existing workspace; - once an entry is observed, delegate to the existing `resolve_existing_score_pdf` authority and convert any symlink, non-regular, canonicalization, containment, permission, concurrent-disappearance, or other validation failure into a removal error; - return `Some(path)` only for an authorized existing score. -The Tauri command therefore returns `false` only for an observed absent directory entry. Unsafe or indeterminate storage remains an error, so buyer metadata is not silently discarded while the file is still present or unverified. This is an absence observation, not a durable non-existence guarantee: another same-user/local actor can create the pathname after `NotFound` and before metadata changes. +The Tauri command therefore returns `false` only for an observed absent child under an existing score workspace. Unsafe or indeterminate storage remains an error, so buyer metadata is not silently discarded while the file is still present or unverified. This is an absence observation, not a durable non-existence guarantee: another same-user/local actor can create the pathname after `NotFound` and before metadata changes. ## Hosted findings and repairs @@ -82,9 +83,11 @@ GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added platform object-identity ### Missing versus unsafe removal resolution -RED contract commit `9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951` introduced `score_pdf_retention_resolution` with four product states: genuinely absent entry, normal regular score, directory masquerading as a score, and Unix symlink replacement, plus a Tauri wiring assertion forbidding blanket `Err(_) => Ok(false)` handling. The then-current workflow did not execute the new retention test target, and run `35459793839` therefore completed successfully instead of providing a valid hosted RED. That workflow omission is itself repaired rather than misreported as RED evidence. +Contract commit `9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951` first introduced `score_pdf_retention_resolution` for genuinely absent entries, normal regular scores, directory masquerades, Unix symlink replacement, and Tauri wiring. The workflow at that commit did not execute the new test target; run `35459793839` therefore completed successfully and is not RED evidence. The workflow omission was repaired by adding `score_pdf_retention_resolution` to the owner command and `score_retention.rs` to its path filter. -The causal repair adds `resolve_score_pdf_for_removal`, exports it through the desktop core, and changes the Tauri command so only an observed `NotFound` returns `false`; unsafe or indeterminate resolution propagates an error. `score-storage-native` now explicitly runs `score_pdf_retention_resolution` and its path filter includes `score_retention.rs`, so future changes to this authority cannot silently bypass the owner lane. +The first implementation then revealed a narrower classification bug: a missing scores workspace caused `symlink_metadata(/.pdf)` to report `NotFound`, which was incorrectly treated as an idempotent missing attachment. RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a` requires an existing workspace before child absence can produce `None`. Exact `score-storage-native` run `35460119001` failed the retention regression on macOS job `105942349093` and Windows Server 2025 job `105942349170`; the Windows log shows all eight owned unit tests, all three publication regressions, and both existing wiring regressions passing before only `removal_resolution_rejects_a_missing_scores_root` failed. + +GREEN `6444f21a158a877ae402fd665469d908f8afa3a2` checks the supplied score workspace with `symlink_metadata` and requires a directory before child lookup. It deliberately does not recreate Project Persistence link/reparse policy; #970 remains the broader workspace authority. Exact run `35460197487` is terminal **SUCCESS** on macOS job `105942555476` and Windows Server 2025 job `105942555631`, including the owner unit suite and publication/retention/wiring regressions. ## Alternatives rejected @@ -92,7 +95,7 @@ The causal repair adds `resolve_score_pdf_for_removal`, exports it through the d A second pathname `stat` before deletion was rejected because it only moves the race. Windows has object-bound deletion through a handle with DELETE authority. On Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`. -Treating all resolver failures as absence was rejected because filesystem corruption, permission failure, symlink/reparse substitution, containment failure, and concurrent mutation are not evidence that the attachment is gone. Error-message string matching was also rejected: the resolver intentionally uses payload-safe generic messages and strings are not a stable domain discriminator. The selected contract performs the narrow `NotFound` classification at the exact directory-entry lookup and preserves every later failure. +Treating all resolver failures as absence was rejected because filesystem corruption, permission failure, symlink/reparse substitution, containment failure, missing workspace, and concurrent mutation are not evidence that the attachment is gone. Error-message string matching was also rejected: the resolver intentionally uses payload-safe generic messages and strings are not a stable domain discriminator. The selected contract performs the narrow child-`NotFound` classification only after the score workspace itself has been observed as a directory, then preserves every later failure. Length-only final publication checks were rejected because a same-length foreign regular file is not the staged score. Path hashing was rejected as the primary identity primitive because it still binds verification to whichever object the pathname resolves to and requires a second full PDF read. The selected contract uses OS object identity plus the descriptor-bound byte count. @@ -102,13 +105,13 @@ Weakening Windows staging share mode was rejected. The write remains non-shareab **Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. -**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → exact directory-entry absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. +**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → existing score-workspace precondition → exact child absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink/non-regular object, identity mismatch, unsafe resolution, and indeterminate I/O fail closed with path/payload-safe diagnostics. Foreign replacements are preserved in the covered final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. +**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink/non-regular object, identity mismatch, missing workspace, unsafe resolution, and indeterminate I/O fail closed with path/payload-safe diagnostics. Foreign replacements are preserved in the covered final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. **Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL and tests that inheritance directly rather than asserting POSIX equivalence. -**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, and removal classification for absent/regular/directory/symlink states. +**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, and removal classification for existing-root absent child, missing workspace, regular file, directory, and Unix symlink states. ## Remaining risk / claim boundary @@ -116,7 +119,7 @@ This work does not prove packaged-app crash or power-loss durability and does no A successful final publication attests that the pathname resolves to the synchronized stage object at that instant; it does not make the name immutable afterward. A later same-user/local replacement belongs to read/path authority. -Windows explicit removal and stage cleanup are object-bound for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup retain final basename races unless a stronger platform primitive or storage invariant is adopted. `Ok(None)` from retention resolution is likewise an observed absence, not an atomic reservation preventing subsequent creation. +Windows explicit removal and stage cleanup are object-bound for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup retain final basename races unless a stronger platform primitive or storage invariant is adopted. `Ok(None)` from retention resolution is likewise an observed child absence under an existing workspace, not an atomic reservation preventing subsequent creation. The implementation remains stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. From 297327de172bbe30cdf226518a5135605db983ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 04:01:35 +0900 Subject: [PATCH 046/166] test(score): prove restart readback across process boundary --- .../core/tests/score_pdf_restart_readback.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_restart_readback.rs diff --git a/apps/desktop/core/tests/score_pdf_restart_readback.rs b/apps/desktop/core/tests/score_pdf_restart_readback.rs new file mode 100644 index 000000000..10a6c32db --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_restart_readback.rs @@ -0,0 +1,76 @@ +use bandscope_desktop_core::{ + publish_score_pdf_attachment, read_validated_score_pdf, resolve_existing_score_pdf, +}; +use std::{ + fs, + path::PathBuf, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +const CHILD_ENV: &str = "BANDSCOPE_SCORE_RESTART_READBACK_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_RESTART_READBACK_ROOT"; +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +const PDF_BYTES: &[u8] = b"%PDF-restart-readback"; + +fn unique_test_dir() -> 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-score-restart-readback-{suffix}")) +} + +fn child_readback() { + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("restart child should receive the score workspace"), + ); + let path = resolve_existing_score_pdf(&root, SCORE_ID) + .expect("restart child should resolve the published score inside the workspace"); + let bytes = read_validated_score_pdf(&path) + .expect("restart child should read the published score through the bounded native reader"); + assert_eq!(bytes, PDF_BYTES); +} + +#[test] +fn published_score_reads_back_in_fresh_process() { + if std::env::var_os(CHILD_ENV).is_some() { + child_readback(); + return; + } + + let root = unique_test_dir(); + fs::create_dir_all(&root).expect("score workspace should be created"); + let source = root.join("selected.pdf"); + fs::write(&source, PDF_BYTES).expect("score fixture should be written"); + + let written = publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect("score publication should succeed before restart"); + assert_eq!(written, PDF_BYTES.len() as u64); + + let status = Command::new(std::env::current_exe().expect("test binary path should resolve")) + .arg("--exact") + .arg("published_score_reads_back_in_fresh_process") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env(ROOT_ENV, &root) + .status() + .expect("restart child should launch"); + assert!(status.success(), "restart readback child must succeed"); + + let stale_stage_count = fs::read_dir(&root) + .expect("score workspace should remain readable") + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".score-") && name.ends_with(".stage") + }) + .count(); + assert_eq!( + stale_stage_count, 0, + "successful publication must not leave a staging alias across restart" + ); + + fs::remove_dir_all(root).expect("restart/readback fixture should be removable"); +} From 0e4070ef6d92d55a91463e8bc4c1fa6b71f86003 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 04:01:47 +0900 Subject: [PATCH 047/166] ci(score): run restart readback acceptance --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index a24c5c0f9..cb758ec52 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -61,10 +61,11 @@ jobs: cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --lib score_storage::tests:: - - name: Run Score Storage publication, retention, and wiring regressions + - name: Run Score Storage publication, retention, restart, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --test score_pdf_attachment_publication --test score_pdf_retention_resolution + --test score_pdf_restart_readback --test score_pdf_attachment_wiring From 89a39092e00ada6a91c6dc1a6e61d1ab5aa7bcc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 04:05:46 +0900 Subject: [PATCH 048/166] docs(score): trace successful restart readback acceptance --- .../score-attachment-publication.md | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 143251d25..27540581a 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -53,6 +53,16 @@ On Windows, cleanup opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_ The Tauri command therefore returns `false` only for an observed absent child under an existing score workspace. Unsafe or indeterminate storage remains an error, so buyer metadata is not silently discarded while the file is still present or unverified. This is an absence observation, not a durable non-existence guarantee: another same-user/local actor can create the pathname after `NotFound` and before metadata changes. +## Successful-publication restart/readback decision + +Restart/readback acceptance is separated from interrupted-publication recovery. + +`score_pdf_restart_readback` publishes a real PDF fixture through `publish_score_pdf_attachment`, then launches a fresh instance of the native Rust test executable. The child process resolves the published score through production `resolve_existing_score_pdf`, reads it through production `read_validated_score_pdf`, and requires exact bytes. After the child exits, the parent requires zero `.score-*.stage` aliases for that successful publication. + +This proves a successfully returned attachment survives process teardown and can be reopened through the normal native authority/read path without relying on in-process state. It also proves the success path retires its stage alias before later process use. + +It does **not** prove recovery from a process kill during publication, cancellation, disk-full, permission failure, power loss, or discovery/cleanup of a stage abandoned before publication completed. Those remain separate #1239/#970 acceptance work. + ## Hosted findings and repairs ### Publication and Windows cleanup @@ -85,9 +95,19 @@ GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added platform object-identity Contract commit `9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951` first introduced `score_pdf_retention_resolution` for genuinely absent entries, normal regular scores, directory masquerades, Unix symlink replacement, and Tauri wiring. The workflow at that commit did not execute the new test target; run `35459793839` therefore completed successfully and is not RED evidence. The workflow omission was repaired by adding `score_pdf_retention_resolution` to the owner command and `score_retention.rs` to its path filter. -The first implementation then revealed a narrower classification bug: a missing scores workspace caused `symlink_metadata(/.pdf)` to report `NotFound`, which was incorrectly treated as an idempotent missing attachment. RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a` requires an existing workspace before child absence can produce `None`. Exact `score-storage-native` run `35460119001` failed the retention regression on macOS job `105942349093` and Windows Server 2025 job `105942349170`; the Windows log shows all eight owned unit tests, all three publication regressions, and both existing wiring regressions passing before only `removal_resolution_rejects_a_missing_scores_root` failed. +The first implementation then revealed a narrower classification bug: a missing scores workspace caused `symlink_metadata(/.pdf)` to report `NotFound`, which was incorrectly treated as an idempotent missing attachment. RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a` requires an existing workspace before child absence can produce `None`. Exact `score-storage-native` run `35460119001` failed the retention regression on macOS job `105942349093` and Windows Server 2025 job `105942349170`; the existing owner suites passed before the targeted failure. + +GREEN `6444f21a158a877ae402fd665469d908f8afa3a2` checks the supplied score workspace with `symlink_metadata` and requires a directory before child lookup. It deliberately does not recreate Project Persistence link/reparse policy; #970 remains the broader workspace authority. Exact run `35460197487` is terminal **SUCCESS** on macOS job `105942555476` and Windows Server 2025 job `105942555631`. -GREEN `6444f21a158a877ae402fd665469d908f8afa3a2` checks the supplied score workspace with `symlink_metadata` and requires a directory before child lookup. It deliberately does not recreate Project Persistence link/reparse policy; #970 remains the broader workspace authority. Exact run `35460197487` is terminal **SUCCESS** on macOS job `105942555476` and Windows Server 2025 job `105942555631`, including the owner unit suite and publication/retention/wiring regressions. +`36325246e060dae612eccad8e619d8e2b5b494c4` made this document code-current for the retention repair. Exact run `35460313972` was terminal SUCCESS on Windows Server 2025 job `105942873778` and macOS job `105942873910`. + +### Successful-publication restart/readback + +`297327de172bbe30cdf226518a5135605db983ef` adds the fresh-process restart/readback regression. `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003` adds that target to the macOS/Windows owner workflow. + +Exact owner run `35463057196` checked out `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003` and passed the owned Score Storage unit suite plus publication, retention, restart/readback, and wiring regressions on macOS job `105950252549` and Windows Server 2025 job `105950252698`. + +This document update is a later exact source identity, so that predecessor GREEN is lineage evidence only. The current head must reacquire owner and repository-wide checks without transferring the `0e4070ef…` verdict. ## Alternatives rejected @@ -101,21 +121,25 @@ Length-only final publication checks were rejected because a same-length foreign Weakening Windows staging share mode was rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. +A same-process reopen was rejected as restart/readback acceptance because it can accidentally rely on process state. The selected regression launches a fresh native test process and traverses the normal resolver plus bounded reader. Conversely, that success-path test is not used as evidence for interruption recovery because it never kills a writer with selected PDF bytes still staged. + ## Security Notes **Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. -**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → existing score-workspace precondition → exact child absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. +**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → existing score-workspace precondition → exact child absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. Restart/readback crosses a process lifetime boundary but does not add IPC, network, or database authority. **Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink/non-regular object, identity mismatch, missing workspace, unsafe resolution, and indeterminate I/O fail closed with path/payload-safe diagnostics. Foreign replacements are preserved in the covered final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. -**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL and tests that inheritance directly rather than asserting POSIX equivalence. +**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL and tests that inheritance directly rather than asserting POSIX equivalence. The restart regression uses only generated PDF fixture bytes under a temporary test workspace; it does not log buyer PDF contents or source paths. -**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, and removal classification for existing-root absent child, missing workspace, regular file, directory, and Unix symlink states. +**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, removal classification for existing-root absent child/missing workspace/regular/directory/Unix symlink states, and successful-publication fresh-process restart/readback with successful-path stage-alias retirement. ## Remaining risk / claim boundary -This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, cancellation/orphan acceptance, restart/readback, and #970 workspace reconciliation remain open. The Windows file-level inheritance contract must be re-run if #970 changes parent-directory ACL authority. +This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, cancellation/interruption orphan acceptance, and #970 workspace reconciliation remain open. The Windows file-level inheritance contract must be re-run if #970 changes parent-directory ACL authority. + +Successful-publication restart/readback is now covered at the native process boundary. It does not prove that an interrupted writer leaves no stage file, that a later process can safely distinguish an orphan from an active writer, or that cancellation/disk-full/power-loss recovery is complete. A successful final publication attests that the pathname resolves to the synchronized stage object at that instant; it does not make the name immutable afterward. A later same-user/local replacement belongs to read/path authority. From 2c55e34c7262d087783502050cd9f084a1f7255c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:03:39 +0900 Subject: [PATCH 049/166] test(score): reproduce process-killed staging orphan --- .../tests/score_pdf_interruption_recovery.rs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_interruption_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_interruption_recovery.rs b/apps/desktop/core/tests/score_pdf_interruption_recovery.rs new file mode 100644 index 000000000..135b360ec --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_interruption_recovery.rs @@ -0,0 +1,114 @@ +use bandscope_desktop_core::publish_score_pdf_attachment; +use std::{ + fs::OpenOptions, + io::Write, + path::{Path, PathBuf}, + process::Command, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const ABANDONED_SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +const NEXT_SCORE_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; +const CHILD_ENV: &str = "BANDSCOPE_SCORE_INTERRUPTION_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_INTERRUPTION_ROOT"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +fn create_private_abandoned_stage(path: &Path) { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path).expect("abandoned stage should be created"); + file.write_all(b"%PDF-1.7\ninterrupted score bytes") + .expect("abandoned score bytes should be written"); + file.sync_all() + .expect("abandoned score bytes should reach the filesystem boundary"); +} + +#[test] +fn process_killed_score_stage_is_recovered_before_next_publication() { + if std::env::var_os(CHILD_ENV).is_some() { + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("child score root should be supplied"), + ); + std::fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{ABANDONED_SCORE_ID}.stage")); + create_private_abandoned_stage(&stage); + std::fs::write(root.join("child-ready"), b"ready") + .expect("child readiness marker should be written"); + + loop { + thread::sleep(Duration::from_secs(60)); + } + } + + let root = unique_test_dir("score-interruption-recovery"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + let expected = b"%PDF-1.7\nreplacement rehearsal score"; + std::fs::write(&source, expected).expect("replacement score fixture should be written"); + + let test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut child = Command::new(test_binary) + .arg("--exact") + .arg("process_killed_score_stage_is_recovered_before_next_publication") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env(ROOT_ENV, &root) + .spawn() + .expect("interruption child should start"); + + let marker = root.join("child-ready"); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(marker.exists(), "child should expose the staged-bytes boundary"); + assert!( + root.join(format!(".score-{ABANDONED_SCORE_ID}.stage")) + .exists(), + "child should leave a synchronized stage before process termination" + ); + + child.kill().expect("interruption child should be terminated"); + let status = child.wait().expect("interruption child should be reaped"); + assert!( + !status.success(), + "the child must end by process termination rather than normal cleanup" + ); + + let written = publish_score_pdf_attachment(&source, &root, NEXT_SCORE_ID) + .expect("the next score publication should recover abandoned staging first"); + assert_eq!(written, expected.len() as u64); + assert_eq!( + std::fs::read(root.join(format!("{NEXT_SCORE_ID}.pdf"))) + .expect("replacement score should be readable"), + expected + ); + + let remaining_stages: Vec<_> = std::fs::read_dir(&root) + .expect("score root should be readable") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with(".score-") && name.ends_with(".stage")) + .collect(); + assert!( + remaining_stages.is_empty(), + "recovery must remove process-abandoned score staging before returning new attachment authority" + ); + + let _ = std::fs::remove_dir_all(root); +} From 45ae83d84f7416f9d0567fa1a47f65f0854a086a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:03:50 +0900 Subject: [PATCH 050/166] test(score): execute interruption recovery regression --- .github/workflows/score-storage-native.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index cb758ec52..ff52e86e7 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -11,6 +11,7 @@ on: - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" - "apps/desktop/core/src/score_retention.rs" + - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -26,6 +27,7 @@ on: - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" - "apps/desktop/core/src/score_retention.rs" + - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -61,11 +63,12 @@ jobs: cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --lib score_storage::tests:: - - name: Run Score Storage publication, retention, restart, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --test score_pdf_attachment_publication --test score_pdf_retention_resolution + --test score_pdf_interruption_recovery --test score_pdf_restart_readback --test score_pdf_attachment_wiring From ad2c4633fba5a9a981859407a035a2b9c367f9ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:04:48 +0900 Subject: [PATCH 051/166] fix(score): recover process-abandoned staging under workspace lease --- apps/desktop/core/src/score_recovery.rs | 226 ++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 apps/desktop/core/src/score_recovery.rs diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs new file mode 100644 index 000000000..8b5b3dcd5 --- /dev/null +++ b/apps/desktop/core/src/score_recovery.rs @@ -0,0 +1,226 @@ +use crate::{is_valid_score_id, score_storage, score_storage::remove_score_pdf_attachment}; +use std::{ + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, +}; + +const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; +const SCORE_RECOVERY_ERROR: &str = "Could not recover the score workspace."; +const SCORE_WORKSPACE_LOCK: &str = ".score-storage.lock"; + +/// Cross-process lease for Score Storage mutation and recovery. +/// +/// The lease file is intentionally persistent and contains no payload. Unix +/// uses a non-blocking exclusive `flock`; Windows opens the lock file with no +/// sharing. Both locks are released by the operating system when the process +/// exits, including abnormal termination. This lets a later BandScope process +/// distinguish an abandoned staging namespace from one owned by another live +/// writer running the same contract. +struct ScoreWorkspaceLease { + _file: File, + root: PathBuf, +} + +#[cfg(unix)] +const LOCK_EX: i32 = 2; +#[cfg(unix)] +const LOCK_NB: i32 = 4; +#[cfg(any(target_os = "linux", target_os = "android"))] +const O_NOFOLLOW: i32 = 0x0002_0000; +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] +const O_NOFOLLOW: i32 = 0x0000_0100; + +#[cfg(unix)] +extern "C" { + fn flock(fd: i32, operation: i32) -> i32; +} + +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + +fn validate_scores_root(scores_root: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(scores_root).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !metadata.is_dir() { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + } + + Ok(()) +} + +#[cfg(unix)] +fn acquire_score_workspace_lease(scores_root: &Path) -> Result { + use std::os::{fd::AsRawFd, unix::fs::OpenOptionsExt}; + + validate_scores_root(scores_root)?; + let lock_path = scores_root.join(SCORE_WORKSPACE_LOCK); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(O_NOFOLLOW) + .open(lock_path) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !file + .metadata() + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())? + .is_file() + { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + let result = unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) }; + if result != 0 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + Ok(ScoreWorkspaceLease { + _file: file, + root: scores_root.to_path_buf(), + }) +} + +#[cfg(windows)] +fn acquire_score_workspace_lease(scores_root: &Path) -> Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + validate_scores_root(scores_root)?; + let lock_path = scores_root.join(SCORE_WORKSPACE_LOCK); + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(lock_path) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + Ok(ScoreWorkspaceLease { + _file: file, + root: scores_root.to_path_buf(), + }) +} + +#[cfg(all(not(unix), not(windows)))] +fn acquire_score_workspace_lease(_scores_root: &Path) -> Result { + Err(SCORE_RECOVERY_ERROR.to_string()) +} + +fn reserved_stage_score_id(name: &str) -> Option<&str> { + let score_id = name.strip_prefix(".score-")?.strip_suffix(".stage")?; + is_valid_score_id(score_id).then_some(score_id) +} + +fn recover_abandoned_score_stages( + scores_root: &Path, + lease: &ScoreWorkspaceLease, +) -> Result { + if lease.root != scores_root { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + + let mut removed = 0_usize; + let entries = fs::read_dir(scores_root).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + for entry in entries { + let entry = entry.map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(score_id) = reserved_stage_score_id(name) else { + continue; + }; + + let stage = entry.path(); + let metadata = fs::symlink_metadata(&stage).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !metadata.is_file() { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + } + + // A destination beside its staging alias means interruption may have + // happened after no-clobber publication. Score metadata persistence is + // not yet transactionally coupled to this storage layer, so deleting + // either pathname here could erase evidence or an attachment whose + // lifecycle authority is not established. Preserve both and fail + // closed; #1239 tracks that later lifecycle/recovery vertical. + let destination = scores_root.join(format!("{score_id}.pdf")); + match fs::symlink_metadata(&destination) { + Ok(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), + } + + remove_score_pdf_attachment(&stage).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + removed += 1; + } + Ok(removed) +} + +/// Publish one score attachment after recovering process-abandoned staging. +/// +/// The workspace lease spans recovery and the complete lower-level publication +/// so another BandScope process using this contract cannot have a live writer +/// mistaken for stale staging. A crash releases the OS lease automatically; +/// the next publication removes only reserved `.score-.stage` regular +/// files for which no `.pdf` destination exists, then proceeds through +/// the existing bounded/private/no-clobber Score Storage publisher. +/// +/// Security Notes: malformed names are ignored because they are outside the +/// owned staging namespace. Reserved symlink/reparse/non-regular entries, +/// lock acquisition failure, unreadable directory state, or a stage with a +/// destination already present fail closed. Errors contain neither absolute +/// paths nor PDF bytes. This recovery closes abandoned *staging* from writers +/// using this lease contract; it does not claim lifecycle authority for a +/// destination created before an interruption or compatibility with an older +/// concurrently running BandScope build that never acquired the lease. +pub fn publish_score_pdf_attachment( + source: &Path, + scores_root: &Path, + score_id: &str, +) -> Result { + if !is_valid_score_id(score_id) { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + let lease = acquire_score_workspace_lease(scores_root)?; + recover_abandoned_score_stages(scores_root, &lease)?; + score_storage::publish_score_pdf_attachment(source, scores_root, score_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserved_stage_name_requires_exact_score_uuid_shape() { + assert_eq!( + reserved_stage_score_id(".score-6fa459ea-ee8a-4ca4-894e-db77e160355e.stage"), + Some("6fa459ea-ee8a-4ca4-894e-db77e160355e") + ); + assert_eq!(reserved_stage_score_id(".score-../escape.stage"), None); + assert_eq!(reserved_stage_score_id("score-6fa459ea-ee8a-4ca4-894e-db77e160355e.stage"), None); + assert_eq!(reserved_stage_score_id(".score-6fa459ea-ee8a-4ca4-894e-db77e160355e.tmp"), None); + } +} From a1f21f1310ab6af96109def3575220e0017e19a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:04:55 +0900 Subject: [PATCH 052/166] fix(score): route attachment publication through crash recovery --- apps/desktop/core/src/root.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 6e212307c..9a587d350 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -8,10 +8,12 @@ #[path = "lib.rs"] mod runtime_core; mod score_pdf; +mod score_recovery; mod score_retention; mod score_storage; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; +pub use score_recovery::publish_score_pdf_attachment; pub use score_retention::resolve_score_pdf_for_removal; -pub use score_storage::{publish_score_pdf_attachment, remove_score_pdf_attachment}; +pub use score_storage::remove_score_pdf_attachment; From f4962d21b77148ff7e90ac4349979d666ef32d75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:05:24 +0900 Subject: [PATCH 053/166] test(score): gate recovery module and process-kill regression --- .github/workflows/score-storage-native.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index ff52e86e7..2371b6a7f 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -10,8 +10,8 @@ on: - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" - - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_recovery.rs" + - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -26,8 +26,8 @@ on: - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/score_pdf.rs" - - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_recovery.rs" + - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" @@ -59,10 +59,14 @@ jobs: - name: Install Rust 1.97.1 run: rustup toolchain install 1.97.1 --profile minimal - name: Run owned Score Storage unit tests - run: >- - cargo +1.97.1 test - --manifest-path apps/desktop/core/Cargo.toml - --lib score_storage::tests:: + shell: bash + run: | + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --lib score_storage::tests:: + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --lib score_recovery::tests:: - name: Run Score Storage publication, retention, interruption, restart, and wiring regressions run: >- cargo +1.97.1 test From a49e9789d8368ab762daa35a3c4497770b45537b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:08:30 +0900 Subject: [PATCH 054/166] docs(score): record process-abandoned staging recovery --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3029834d..147655925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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. - 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. +- Recover process-abandoned score-PDF staging before the next attachment publication under an OS-released cross-process workspace lease; ambiguous stage-plus-destination state is preserved and fails closed instead of being deleted by a blind sweep. ## [0.1.3] - 2026-04-29 @@ -76,4 +77,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From 8ce797171ca9eb6cb910a311c86ea965518ca184 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:09:22 +0900 Subject: [PATCH 055/166] docs(score): trace process-abandoned staging recovery --- .../score-attachment-publication.md | 167 +++++++----------- 1 file changed, 67 insertions(+), 100 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 27540581a..586bba7f2 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -5,147 +5,116 @@ Canonical owner: Score Storage / Score Attachment ## Problem and owner boundary -The desktop attachment command originally admitted a PDF and copied it into `/scores` with `std::fs::copy`. That left first-visible permissions, source mutation during copy, no-clobber publication, partial-copy cleanup, final-object identity, and retention semantics implicit. +Score attachment bytes are buyer data. This bounded context owns score-PDF write-time confidentiality, bounded publication, final-object identity, abandoned-stage recovery, explicit removal and retention semantics. Native read-time allocation/content validation remains #865. App-owned project/workspace link, ACL and lifecycle authority remains Project Persistence #970. Score Storage consumes that workspace as a narrow filesystem boundary and does not copy Project Persistence policy. -The native read-time 25 MiB allocation/content guard remains #865. Project/workspace directory authority remains Project Persistence #970. This lane owns score attachment publication and explicit attachment removal/retention only. +The original desktop command copied a selected PDF with `std::fs::copy`. Subsequent repairs established a descriptor-bounded 25 MiB copy, private staging, no-clobber publication, OS-object identity attestation and object-aware deletion. Two lifecycle problems are distinct: -Deletion had two separate authority problems. First, the product resolved and canonicalized `.pdf` and later removed a pathname, allowing validation and deletion to refer to different objects. Second, the Tauri command treated every `resolve_existing_score_pdf` error as `Ok(false)`. That collapsed a genuinely absent entry together with symlink/non-regular, containment, canonicalization, permission, and other indeterminate states. The UI could therefore treat unsafe storage as "already gone" and remove attachment metadata. +1. a successfully returned attachment must survive process teardown and fresh-process readback; +2. a process can die after selected PDF bytes have reached `.score-.stage`, leaving buyer bytes behind even though no attachment authority was returned. -Publication also had two independent identity gaps. Windows stage cleanup initially compared `FILE_ID_INFO`, closed that handle, and then unlinked a pathname, so a late replacement could redirect cleanup. Final publication initially accepted any regular destination of the expected length, so a same-length foreign file could be accepted as the score. +The first is successful-publication durability/readability. The second is interrupted staging recovery. Neither is Project Persistence project-file recovery. -## Publication decision +## Publication contract -`publish_score_pdf_attachment` is the native Score Storage publication boundary. +`score_storage::publish_score_pdf_attachment` remains the lower-level publication primitive. -- Reopen the already-admitted source once and bind copy to that descriptor. -- Snapshot descriptor length and reject `0` or `> 25 MiB` before copy. -- Copy through a fixed 64 KiB buffer. Early EOF is truncation; a one-byte probe after the snapshot detects growth. Both fail closed. -- Revalidate `%PDF-` on the bytes actually copied. -- Stage with `create_new`. Unix requests `0600` at first visibility rather than creating broad permissions and tightening them later. -- Windows inherits the app-owned scores-directory DACL. Native acceptance requires the child DACL to remain unprotected (`SE_DACL_PROTECTED` clear) and contain at least one `INHERITED_ACE`; this is the Windows contract rather than a POSIX `0600` analogy. -- Windows denies sharing during the untrusted write. The synchronized stage handle closes before hard-link publication because pathname mutation is intentionally denied while that handle is live. -- Publish with a hard link to `.pdf`; an existing score id is never overwritten. -- Before returning attachment authority, attest that the destination is the exact synchronized stage object: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. Reject symlink/reparse and non-regular destinations. Repeat the identity check after retiring the temporary stage alias. -- Return the byte count from the descriptor-bound copy rather than an earlier path-validation size snapshot. +- Reopen the admitted source once and bind copy to that descriptor. +- Snapshot descriptor length; reject zero and `> 25 MiB`. +- Copy with a fixed 64 KiB buffer; early EOF and post-copy growth probe fail closed. +- Revalidate `%PDF-` on bytes actually copied. +- Stage with `create_new`; Unix requests `0600` at first visibility. Windows inherits the app-owned parent DACL and native acceptance verifies inherited ACE evidence. +- Keep the Windows write handle non-shareable until `sync_all` finishes. +- Publish by hard-link to `.pdf`; never overwrite an existing score id. +- Attest destination identity to the synchronized stage object before and after retiring the stage alias: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. +- Return the descriptor-bound byte count, not a stale path metadata value. -### Stage cleanup authority +Windows stage cleanup reopens the exact non-reparse stage with DELETE authority, verifies file identity and applies `FileDispositionInfo` to that handle. Unix cleanup still has a documented final identity-check-to-path-unlink race. -On Unix, stage identity is device/inode from the owned stage. Cleanup compares identity before pathname unlink. A final identity-check-to-unlink name race remains explicit. +## Process-abandoned staging recovery -On Windows, cleanup opens the stage with `DELETE | FILE_READ_ATTRIBUTES`, `FILE_FLAG_OPEN_REPARSE_POINT`, and read/write/delete sharing; rejects reparse/non-regular objects; verifies volume serial + 128-bit file id against the staging identity; then calls `SetFileInformationByHandle(FileDispositionInfo)` on that same handle. A foreign replacement cannot redirect deletion to itself. +The crate-root `publish_score_pdf_attachment` now routes through `score_recovery` before calling the lower-level publisher. -## Retention/delete decision +A cross-process Score Storage workspace lease spans both recovery and the complete publication: -`remove_score_pdf_attachment` owns final score-file deletion after Score Storage has resolved an authorized attachment. +- Unix opens persistent `.score-storage.lock` with `O_NOFOLLOW`, mode `0600`, then takes non-blocking exclusive `flock`. +- Windows opens the persistent lock file with no sharing and `FILE_FLAG_OPEN_REPARSE_POINT`; a reparse lock object is rejected. +- The operating system releases the held lease when the process exits, including abnormal termination. The lock file itself contains no buyer payload. +- Recovery only examines the reserved exact namespace `.score-.stage`. Malformed names are outside Score Storage ownership and are ignored rather than broadened into a glob-delete contract. +- A reserved stage must be a regular, non-reparse object. Suspicious reserved objects fail closed. +- A stage-only orphan with no matching `.pdf` is removed through the existing Score Storage object-deletion boundary. +- If both `.score-.stage` and `.pdf` exist, recovery fails closed and preserves both. Interruption may have happened after hard-link publication but before higher-level attachment metadata persistence; Score Storage does not infer buyer lifecycle intent from those two names. -- Windows opens the exact non-reparse object with `DELETE | FILE_READ_ATTRIBUTES` and marks that handle with `FileDispositionInfo`. -- Unix pins the parent directory, opens the score basename with `openat(..., O_NOFOLLOW)`, captures device/inode, reopens under the same parent descriptor immediately before `unlinkat`, and fails closed if identity changed. -- Portable POSIX `unlinkat` removes a directory entry rather than an arbitrary already-open object. Parent pinning removes ancestor substitution but not the final identity-check-to-`unlinkat` basename race. +This lease prevents another process that uses the same current Score Storage contract from being mistaken for a stale writer. It deliberately does **not** claim compatibility with an older concurrently running BandScope build that never acquires the lease. Broader ancestor-link/workspace authority still belongs to #970 and requires fresh reconciliation after #970 integrates. -`resolve_score_pdf_for_removal` owns the missing-versus-unsafe classification before deletion: +## Retention/delete contract -- validate the score id before joining a local name; -- require the supplied scores workspace itself to exist as a directory before interpreting any child `NotFound`; broader app-owned workspace/link authority remains Project Persistence #970; -- inspect the exact `.pdf` directory entry with `symlink_metadata`; -- return `Ok(None)` only when that child lookup reports `ErrorKind::NotFound` under the existing workspace; -- once an entry is observed, delegate to the existing `resolve_existing_score_pdf` authority and convert any symlink, non-regular, canonicalization, containment, permission, concurrent-disappearance, or other validation failure into a removal error; -- return `Some(path)` only for an authorized existing score. +`resolve_score_pdf_for_removal` distinguishes genuine absence from unsafe or indeterminate storage: -The Tauri command therefore returns `false` only for an observed absent child under an existing score workspace. Unsafe or indeterminate storage remains an error, so buyer metadata is not silently discarded while the file is still present or unverified. This is an absence observation, not a durable non-existence guarantee: another same-user/local actor can create the pathname after `NotFound` and before metadata changes. +- validate score id before local name construction; +- require the supplied scores workspace to exist as a directory before a child `NotFound` can mean absence; +- return `Ok(None)` only for an observed missing `.pdf` child under that existing workspace; +- once an entry exists, preserve symlink/non-regular/canonicalization/containment/permission/concurrent-disappearance failures as errors. -## Successful-publication restart/readback decision +`remove_score_pdf_attachment` then owns final object deletion. Windows marks the exact opened object for deletion with `FileDispositionInfo`. Unix pins the parent directory, opens the basename with `openat(..., O_NOFOLLOW)`, rechecks device/inode and then calls `unlinkat`; the final basename race remains explicit. -Restart/readback acceptance is separated from interrupted-publication recovery. +## Successful-publication restart/readback -`score_pdf_restart_readback` publishes a real PDF fixture through `publish_score_pdf_attachment`, then launches a fresh instance of the native Rust test executable. The child process resolves the published score through production `resolve_existing_score_pdf`, reads it through production `read_validated_score_pdf`, and requires exact bytes. After the child exits, the parent requires zero `.score-*.stage` aliases for that successful publication. +`score_pdf_restart_readback` publishes through the production crate-root boundary, starts a fresh native test process, resolves the stored score through `resolve_existing_score_pdf`, reads it through `read_validated_score_pdf` and requires exact bytes. The success path also requires zero `.score-*.stage` aliases after publication. -This proves a successfully returned attachment survives process teardown and can be reopened through the normal native authority/read path without relying on in-process state. It also proves the success path retires its stage alias before later process use. +This proves a successfully returned attachment survives one process lifetime and is readable through the normal bounded native path. It is separate from interrupted-writer recovery. -It does **not** prove recovery from a process kill during publication, cancellation, disk-full, permission failure, power loss, or discovery/cleanup of a stage abandoned before publication completed. Those remain separate #1239/#970 acceptance work. +## Interruption RED and repair -## Hosted findings and repairs +Commit `2c55e34c7262d087783502050cd9f084a1f7255c` added `score_pdf_interruption_recovery`. A child native test process writes and synchronizes selected PDF fixture bytes into the exact reserved `.score-.stage` shape, exposes a readiness marker and remains alive. The parent confirms the stage exists, terminates and reaps that child, then attempts a different score publication through the production crate-root API and requires all score staging aliases to be gone before attachment authority returns. -### Publication and Windows cleanup +`45ae83d84f7416f9d0567fa1a47f65f0854a086a` put that regression into the macOS/Windows owner workflow. Descendant pushes superseded the early workflow run before a terminal RED verdict, so no hosted RED is claimed for that source. The pre-repair behavior is nevertheless a deterministic source RED: publishing a different UUID had no code path that removed the abandoned earlier stage, so the final zero-stage assertion could not hold. -Initial publication RED `f8ba40d10458ed6b7b3b0e9d4d8f79ec866c50df` established the publication contract before production code. That source-level RED was superseded before a terminal hosted compiler verdict, so no hosted claim is made for it. +`ad2c4633fba5a9a981859407a035a2b9c367f9ec` added the OS-released workspace lease and reserved-stage recovery. `a1f21f1310ab6af96109def3575220e0017e19a2` routed the public crate-root publisher through recovery. `f4962d21b77148ff7e90ac4349979d666ef32d75` tightened the owner workflow so both lower-level Score Storage and recovery unit suites plus publication, retention, interruption, restart and wiring regressions execute. -Exact-source run `35451457938` on `69463eba53b61ba0505e4121317e3d726bfa4096` isolated a Windows sharing contract: macOS passed, while Windows publication failed because a `share_mode(0)` stage handle was still live during hard-link/unlink. The repair keeps exclusive sharing during untrusted write, captures identity, synchronizes, and only then closes before publication. +At `f4962d21b77148ff7e90ac4349979d666ef32d75`, Windows Server 2025 job `105959193774` in run `35466321613` passed checkout, Rust 1.97.1, both owned unit suites and all publication/retention/interruption/restart/wiring regressions. The document update is a later source identity, so exact-current-head native evidence must be reacquired and recorded on PR #1241 rather than transferring this predecessor verdict. -RED `a7373ca153e8740923ad13098953a61a7c263cc0` replaced the Windows stage pathname with a foreign regular file. Run `35452152802`, Windows job `105921067591`, failed that regression while macOS job `105921067494` remained green. GREEN `49976461c7158869d1f10a6a43b46e0ee522352f` switched comparison to `GetFileInformationByHandleEx(FileIdInfo)`. +The process-kill acceptance is intentionally scoped: it verifies recovery of a synchronized **stage-only** artifact left by a terminated process, followed by a production publication. It does not claim that the test kills the lower-level publisher at every internal instruction point. Packaged-app process-kill, disk-full, permission failure, explicit cancellation, power loss and stage-plus-destination lifecycle recovery remain separate acceptance. -RED `89f136e312ded829b89f2beafda68ca1c373044c` then replaced the stage after Windows identity validation but before the old pathname unlink. Exact run `35454589087` kept macOS job `105927525260` green and failed Windows job `105927525168`. GREEN `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` keeps the identity-matched delete handle open and applies `FileDispositionInfo`; run `35454808019` passed macOS `105928096806` and Windows `105928096678`. +## Earlier hosted evidence retained -### Product deletion authority - -RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd` required the Tauri removal command to use Score Storage deletion rather than direct `std::fs::remove_file`. Exact run `35453934315` failed the wiring regression on macOS `105925792583` and Windows Server 2025 `105925792679`. GREEN `09610fb26127408f818a754a42b75a083e0e5045` routed removal through `remove_score_pdf_attachment`; run `35454259017` passed macOS `105926649166` and Windows `105926649120`. - -The Windows replacement regression acquires the delete handle, renames the opened score, creates a foreign file at the former pathname, then marks only the open handle for deletion. The replacement survives. The Unix counterpart replaces the basename before the second descriptor-relative identity check and requires fail-closed preservation. - -### Windows ACL inheritance - -Commit `579a67d4767d098d0dd608b5678d59e66efe1701` inspects the published child security descriptor using `GetNamedSecurityInfoW`, `GetSecurityDescriptorControl`, `GetAclInformation`, and `GetAce`. Exact run `35455174325` passed macOS job `105929062031` and Windows Server 2025 job `105929062235`, proving the selected child-inheritance contract for the current parent directory model. A changed workspace ACL after #970 integration requires fresh acceptance; this evidence is not transferable. - -### Final publication object identity - -RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212` moved the owned hard link aside and wrote a different same-length regular PDF at `.pdf`. Exact run `35456813143` failed the owned unit-test step on macOS `105933454955` and Windows Server 2025 `105933455061`, proving regular-file + length attestation insufficient. - -GREEN `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added platform object-identity attestation. Exact native run `35456879153` passed macOS `105933633903` and Windows `105933633950`. - -### Missing versus unsafe removal resolution - -Contract commit `9ba8c0b9ceeb8b6b3fbaa33fb858af9db1b9b951` first introduced `score_pdf_retention_resolution` for genuinely absent entries, normal regular scores, directory masquerades, Unix symlink replacement, and Tauri wiring. The workflow at that commit did not execute the new test target; run `35459793839` therefore completed successfully and is not RED evidence. The workflow omission was repaired by adding `score_pdf_retention_resolution` to the owner command and `score_retention.rs` to its path filter. - -The first implementation then revealed a narrower classification bug: a missing scores workspace caused `symlink_metadata(/.pdf)` to report `NotFound`, which was incorrectly treated as an idempotent missing attachment. RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a` requires an existing workspace before child absence can produce `None`. Exact `score-storage-native` run `35460119001` failed the retention regression on macOS job `105942349093` and Windows Server 2025 job `105942349170`; the existing owner suites passed before the targeted failure. - -GREEN `6444f21a158a877ae402fd665469d908f8afa3a2` checks the supplied score workspace with `symlink_metadata` and requires a directory before child lookup. It deliberately does not recreate Project Persistence link/reparse policy; #970 remains the broader workspace authority. Exact run `35460197487` is terminal **SUCCESS** on macOS job `105942555476` and Windows Server 2025 job `105942555631`. - -`36325246e060dae612eccad8e619d8e2b5b494c4` made this document code-current for the retention repair. Exact run `35460313972` was terminal SUCCESS on Windows Server 2025 job `105942873778` and macOS job `105942873910`. - -### Successful-publication restart/readback - -`297327de172bbe30cdf226518a5135605db983ef` adds the fresh-process restart/readback regression. `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003` adds that target to the macOS/Windows owner workflow. - -Exact owner run `35463057196` checked out `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003` and passed the owned Score Storage unit suite plus publication, retention, restart/readback, and wiring regressions on macOS job `105950252549` and Windows Server 2025 job `105950252698`. - -This document update is a later exact source identity, so that predecessor GREEN is lineage evidence only. The current head must reacquire owner and repository-wide checks without transferring the `0e4070ef…` verdict. +- Windows share-mode repair: run `35451457938` isolated the hard-link/unlink failure while the exclusive write handle was live. +- Windows foreign-stage replacement RED `a7373ca153e8740923ad13098953a61a7c263cc0`: run `35452152802`, Windows `105921067591`, failed; `49976461c7158869d1f10a6a43b46e0ee522352f` moved identity to `FILE_ID_INFO`. +- Windows identity-check-to-unlink RED `89f136e312ded829b89f2beafda68ca1c373044c`: run `35454589087` failed Windows `105927525168`; `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` made cleanup handle-bound and run `35454808019` passed Windows/macOS. +- Tauri direct-delete wiring RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd`: run `35453934315` failed Windows/macOS; `09610fb26127408f818a754a42b75a083e0e5045` routed removal through Score Storage and run `35454259017` passed. +- Windows inherited-DACL acceptance `579a67d4767d098d0dd608b5678d59e66efe1701`: run `35455174325` passed macOS `105929062031` and Windows `105929062235`. +- Final destination identity RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212`: run `35456813143` failed macOS `105933454955` and Windows `105933455061`; `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added OS-object identity and run `35456879153` passed. +- Missing-workspace retention RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a`: run `35460119001` failed macOS `105942349093` and Windows `105942349170`; `6444f21a158a877ae402fd665469d908f8afa3a2` repaired classification and run `35460197487` passed. +- Successful-publication fresh-process readback: `297327de172bbe30cdf226518a5135605db983ef` plus workflow wiring `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003`; run `35463057196` passed macOS `105950252549` and Windows `105950252698`. ## Alternatives rejected -`std::fs::copy` was rejected because it does not express publication invariants as one auditable boundary. Process-wide `umask` mutation was rejected because it affects unrelated threads. Create-then-`chmod` was rejected because bytes can be visible before tightening. Overwriting a UUID destination was rejected because correctness must not rely on collision probability when no-clobber publication exists. - -A second pathname `stat` before deletion was rejected because it only moves the race. Windows has object-bound deletion through a handle with DELETE authority. On Unix, absolute-path reopening was rejected in favor of a pinned parent descriptor plus `openat`/`unlinkat`. - -Treating all resolver failures as absence was rejected because filesystem corruption, permission failure, symlink/reparse substitution, containment failure, missing workspace, and concurrent mutation are not evidence that the attachment is gone. Error-message string matching was also rejected: the resolver intentionally uses payload-safe generic messages and strings are not a stable domain discriminator. The selected contract performs the narrow child-`NotFound` classification only after the score workspace itself has been observed as a directory, then preserves every later failure. - -Length-only final publication checks were rejected because a same-length foreign regular file is not the staged score. Path hashing was rejected as the primary identity primitive because it still binds verification to whichever object the pathname resolves to and requires a second full PDF read. The selected contract uses OS object identity plus the descriptor-bound byte count. +Blind `.score-*.stage` glob deletion is rejected because a live concurrent writer can own those bytes. File age/mtime heuristics are rejected because elapsed time is not ownership or liveness proof. PID-only or mutable sidecar ownership is rejected because stale metadata and process-ID reuse do not create a robust lease. The selected OS-held lease dies with the process and spans recovery plus publication. -Weakening Windows staging share mode was rejected. The write remains non-shareable until `sync_all`; only the completed stage is reopened under the narrower deletion contract. +Deleting a matching destination while recovering a stage is rejected because filesystem publication is not yet transactionally coupled to the buyer-visible attachment metadata lifecycle. A stage-plus-destination state is ambiguous and is preserved for the later lifecycle/recovery vertical. -A same-process reopen was rejected as restart/readback acceptance because it can accidentally rely on process state. The selected regression launches a fresh native test process and traverses the normal resolver plus bounded reader. Conversely, that success-path test is not used as evidence for interruption recovery because it never kills a writer with selected PDF bytes still staged. +`std::fs::copy`, create-then-`chmod`, process-wide `umask` mutation, overwrite publication, length-only destination checks, pathname hashing as primary identity and a second pathname `stat` before deletion remain rejected for the reasons encoded in the corresponding REDs: each leaves visibility, mutation, clobber or pathname/object identity gaps. ## Security Notes -**Untrusted input.** Selected PDF bytes/path and any pre-existing score destination, staging, or retention name are untrusted. File-dialog paths and PDF bytes are not echoed to the WebView in errors. +**Untrusted input.** Selected PDF bytes/path and pre-existing destination, stage, lock and retention names are untrusted. No selected PDF bytes or absolute buyer path are emitted in ordinary errors. -**Trust boundaries.** OS-selected source → source admission → descriptor-bound Score Storage stage → no-clobber, object-identity-attested attachment. Removal is validated score id → existing score-workspace precondition → exact child absence classification → existing in-root path authority → Score Storage OS-object deletion boundary. Restart/readback crosses a process lifetime boundary but does not add IPC, network, or database authority. +**Trust boundaries.** OS-selected source → source admission → Score Storage workspace lease → reserved-stage recovery → descriptor-bounded private stage → no-clobber/object-identity-attested attachment. Removal remains validated score id → existing workspace precondition → exact child resolution → OS-object deletion. #970 still owns broader app-owned workspace ancestry/link authority. -**Safe failure.** Oversize, truncation, growth, wrong magic, duplicate destination, copy/sync failure, reparse/symlink/non-regular object, identity mismatch, missing workspace, unsafe resolution, and indeterminate I/O fail closed with path/payload-safe diagnostics. Foreign replacements are preserved in the covered final-publication, Windows object-bound cleanup, and Unix pre-`unlinkat` replacement cases. +**Safe failure.** Oversize, growth/truncation, wrong magic, duplicate destination, copy/sync failure, suspicious reserved stage, reparse/symlink/non-regular object, identity mismatch, lease acquisition failure, missing/indeterminate workspace, unsafe resolution and stage-plus-destination ambiguity fail closed. Recovery does not turn ambiguous lifecycle state into deletion. -**Privacy.** Unix publication requests `0600` at first visibility. Windows publication deliberately inherits the app-owned parent DACL and tests that inheritance directly rather than asserting POSIX equivalence. The restart regression uses only generated PDF fixture bytes under a temporary test workspace; it does not log buyer PDF contents or source paths. +**Privacy.** Unix stage and lock creation request `0600`. Windows deliberately consumes the parent DACL contract. The lock file contains no PDF payload. The native interruption fixture uses generated test PDF bytes only. -**Test points.** Native tests cover valid/no-clobber publication, same-length foreign final-destination replacement, permissive-`umask(000)` Unix first visibility, Windows parent-DACL inheritance, source growth/truncation, wrong magic, Tauri publication/deletion wiring, Windows post-close stage replacement, Windows late-stage replacement after identity acquisition, ordinary authorized deletion, Windows retention replacement after delete-handle acquisition, Unix replacement before the second descriptor-relative identity check, removal classification for existing-root absent child/missing workspace/regular/directory/Unix symlink states, and successful-publication fresh-process restart/readback with successful-path stage-alias retirement. +**Test points.** Native tests cover valid/no-clobber publication, same-length destination replacement, Unix permissive-umask privacy, Windows parent-DACL inheritance, source growth/truncation, Tauri wiring, Windows handle-bound cleanup/delete replacement cases, Unix pre-`unlinkat` replacement, removal classification, successful fresh-process readback, reserved-stage namespace parsing, and process-terminated stage-only recovery before the next production publication. ## Remaining risk / claim boundary -This work does not prove packaged-app crash or power-loss durability and does not make Score Storage part of Project Persistence. Parent-directory durability, project deletion semantics, buyer-visible detach/project lifecycle, cancellation/interruption orphan acceptance, and #970 workspace reconciliation remain open. The Windows file-level inheritance contract must be re-run if #970 changes parent-directory ACL authority. +Stage-only process-kill recovery is now implemented under the current Score Storage lease contract. Still open are interruption after a destination has been hard-linked, explicit cancellation, disk-full, permission failure, power-loss durability, detach/project-delete/recovery rollback semantics, packaged-app fault evidence, and compatibility with an older concurrently running build that does not acquire the lease. -Successful-publication restart/readback is now covered at the native process boundary. It does not prove that an interrupted writer leaves no stage file, that a later process can safely distinguish an orphan from an active writer, or that cancellation/disk-full/power-loss recovery is complete. +The Windows parent-DACL acceptance must be rerun after #970 workspace authority integrates. Parent-directory durability and project lifecycle remain #970 concerns; Score Storage must reconcile without copying that implementation. -A successful final publication attests that the pathname resolves to the synchronized stage object at that instant; it does not make the name immutable afterward. A later same-user/local replacement belongs to read/path authority. +A successful final publication attests object identity at the covered boundary but does not make the pathname immutable afterward. Windows cleanup/delete is object-bound for the tested contract. Unix cleanup/delete retains documented final basename races. A removal `Ok(None)` remains an observed absence, not an atomic reservation. -Windows explicit removal and stage cleanup are object-bound for the tested local-filesystem contract. Unix explicit removal and Unix stage cleanup retain final basename races unless a stronger platform primitive or storage invariant is adopted. `Ok(None)` from retention resolution is likewise an observed child absence under an existing workspace, not an atomic reservation preventing subsequent creation. - -The implementation remains stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 must integrate first. Any later restack requires fresh exact-head evidence; predecessor CI is not transferable. +The implementation remains stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 must integrate first. Any descendant restack requires fresh exact-head evidence; predecessor CI is not transferable. ## References @@ -157,14 +126,12 @@ Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. M Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle -Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules +Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea -Microsoft. (n.d.). *Automatic propagation of inheritable ACEs*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/automatic-propagation-of-inheritable-aces +Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow -Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea - The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html The Open Group. (2024). *open, openat — open file relative to a directory file descriptor*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html From 17717abe74fc03ede0359106ff10f3b2a75c0f35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:10:15 +0900 Subject: [PATCH 056/166] test(score): prove lease contention and conservative recovery --- apps/desktop/core/src/score_recovery.rs | 65 ++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 8b5b3dcd5..0f82389d1 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -212,15 +212,78 @@ pub fn publish_score_pdf_attachment( #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + + 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-score-recovery-{name}-{suffix}")) + } #[test] fn reserved_stage_name_requires_exact_score_uuid_shape() { assert_eq!( reserved_stage_score_id(".score-6fa459ea-ee8a-4ca4-894e-db77e160355e.stage"), - Some("6fa459ea-ee8a-4ca4-894e-db77e160355e") + Some(SCORE_ID) ); assert_eq!(reserved_stage_score_id(".score-../escape.stage"), None); assert_eq!(reserved_stage_score_id("score-6fa459ea-ee8a-4ca4-894e-db77e160355e.stage"), None); assert_eq!(reserved_stage_score_id(".score-6fa459ea-ee8a-4ca4-894e-db77e160355e.tmp"), None); } + + #[test] + fn workspace_lease_rejects_a_second_live_writer() { + let root = unique_test_dir("lease-contention"); + fs::create_dir_all(&root).expect("score root should be created"); + let first = acquire_score_workspace_lease(&root).expect("first writer should acquire lease"); + + let second = acquire_score_workspace_lease(&root); + + assert_eq!(second.err().as_deref(), Some(SCORE_RECOVERY_ERROR)); + drop(first); + acquire_score_workspace_lease(&root).expect("lease should release when the first owner drops"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn recovery_removes_only_stage_without_destination() { + let root = unique_test_dir("stage-only"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + fs::write(&stage, b"%PDF-1.7\nabandoned").expect("stage fixture should be written"); + let lease = acquire_score_workspace_lease(&root).expect("recovery should acquire lease"); + + let removed = recover_abandoned_score_stages(&root, &lease) + .expect("stage-only orphan should be recoverable"); + + assert_eq!(removed, 1); + assert!(!stage.exists()); + drop(lease); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn recovery_preserves_ambiguous_stage_plus_destination() { + let root = unique_test_dir("stage-and-destination"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + fs::write(&stage, b"%PDF-1.7\nstage").expect("stage fixture should be written"); + fs::write(&destination, b"%PDF-1.7\ndestination") + .expect("destination fixture should be written"); + let lease = acquire_score_workspace_lease(&root).expect("recovery should acquire lease"); + + let error = recover_abandoned_score_stages(&root, &lease) + .expect_err("ambiguous published state must fail closed"); + + assert_eq!(error, SCORE_RECOVERY_ERROR); + assert!(stage.exists()); + assert!(destination.exists()); + drop(lease); + let _ = fs::remove_dir_all(root); + } } From abed538d02e4813286ea62ac5c3aab914b89a774 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 05:12:09 +0900 Subject: [PATCH 057/166] test(score): prove live-writer exclusion across processes --- apps/desktop/core/src/score_recovery.rs | 58 +++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 0f82389d1..2abbe9f5a 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -212,9 +212,15 @@ pub fn publish_score_pdf_attachment( #[cfg(test)] mod tests { use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::{ + process::Command, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + }; const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + const LEASE_CHILD_ENV: &str = "BANDSCOPE_SCORE_LEASE_CHILD"; + const LEASE_ROOT_ENV: &str = "BANDSCOPE_SCORE_LEASE_ROOT"; fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() @@ -236,16 +242,52 @@ mod tests { } #[test] - fn workspace_lease_rejects_a_second_live_writer() { + fn workspace_lease_child() { + if std::env::var_os(LEASE_CHILD_ENV).is_none() { + return; + } + let root = PathBuf::from( + std::env::var_os(LEASE_ROOT_ENV).expect("lease child root should be supplied"), + ); + let _lease = acquire_score_workspace_lease(&root).expect("lease child should acquire lease"); + fs::write(root.join("lease-ready"), b"ready") + .expect("lease child readiness marker should be written"); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + + #[test] + fn workspace_lease_rejects_a_live_process_and_releases_after_kill() { let root = unique_test_dir("lease-contention"); fs::create_dir_all(&root).expect("score root should be created"); - let first = acquire_score_workspace_lease(&root).expect("first writer should acquire lease"); - - let second = acquire_score_workspace_lease(&root); + let test_binary = std::env::current_exe().expect("unit test binary should resolve"); + let mut child = Command::new(test_binary) + .arg("--exact") + .arg("score_recovery::tests::workspace_lease_child") + .arg("--nocapture") + .env(LEASE_CHILD_ENV, "1") + .env(LEASE_ROOT_ENV, &root) + .spawn() + .expect("lease child should start"); + + let marker = root.join("lease-ready"); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(marker.exists(), "child should hold the workspace lease"); + assert_eq!( + acquire_score_workspace_lease(&root).err().as_deref(), + Some(SCORE_RECOVERY_ERROR), + "a second process must not enter recovery while the first writer is live" + ); - assert_eq!(second.err().as_deref(), Some(SCORE_RECOVERY_ERROR)); - drop(first); - acquire_score_workspace_lease(&root).expect("lease should release when the first owner drops"); + child.kill().expect("lease child should terminate"); + let status = child.wait().expect("lease child should be reaped"); + assert!(!status.success(), "lease child should end by termination"); + acquire_score_workspace_lease(&root) + .expect("the OS lease should become available after process termination"); let _ = fs::remove_dir_all(root); } From 5450dbbb7dfdb449c393e1b4d94bc60559db4989 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 07:33:02 +0900 Subject: [PATCH 058/166] test(score): require metadata durability before attachment acceptance --- .../ScoreView.persistenceOutcome.test.tsx | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx diff --git a/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx b/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx new file mode 100644 index 000000000..67a8788e4 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; +import { ScoreView } from "./ScoreView"; +import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; + +vi.mock("./scoreStorage", () => ({ + attachScorePdf: vi.fn(), + readScorePdf: vi.fn(), + removeScorePdf: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => ( +
+ {data ? `bytes:${data.length}` : "no-data"} + {fileName ? `:${fileName}` : ""} +
+ ) +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreListEmpty: "No scores attached to this song yet.", + scoreAttach: "Add score", + scoreAttaching: "Attaching...", + scoreRemove: "Remove", + scoreRemoveConfirm: "Remove {fileName} from this song?", + scoreOpen: "Open score", + scoreOpening: "Opening score PDF...", + scoreAttachFailed: "Could not attach the score PDF.", + scoreReadFailed: "Could not open the score PDF.", + scoreRemoveFailed: "Could not remove the score PDF.", + scoreRequiresProject: "Scores attach to the active analysis project." + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +const mockAttachScorePdf = vi.mocked(attachScorePdf); +const mockReadScorePdf = vi.mocked(readScorePdf); +const mockRemoveScorePdf = vi.mocked(removeScorePdf); +const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; + +function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { + return { + id: "song-1", + title: "Late Night Set", + sections: [], + exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }, + ...(scoreAttachments ? { scoreAttachments } : {}) + } as RehearsalSong; +} + +describe("ScoreView persistence outcome ordering", () => { + beforeEach(() => { + mockAttachScorePdf.mockReset(); + mockReadScorePdf.mockReset(); + mockRemoveScorePdf.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not present a newly published score as accepted when project metadata persistence rejects it", async () => { + mockAttachScorePdf.mockResolvedValue({ + id: SCORE_ID, + fileName: "opener.pdf", + fileSizeBytes: 2048 + }); + const onSongUpdate = vi.fn().mockResolvedValue(false); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByRole("button", { name: "Add score" })).toBeEnabled()); + expect(mockAttachScorePdf).toHaveBeenCalledTimes(1); + expect(mockReadScorePdf).not.toHaveBeenCalled(); + expect(mockRemoveScorePdf).not.toHaveBeenCalled(); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); + + it("does not delete score bytes when durable metadata removal is rejected", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn().mockResolvedValue(false); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + expect(mockRemoveScorePdf).not.toHaveBeenCalled(); + }); + + it("commits metadata removal before deleting the stored score object", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn().mockResolvedValue(true); + mockRemoveScorePdf.mockResolvedValue(true); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + await waitFor(() => expect(mockRemoveScorePdf).toHaveBeenCalledTimes(1)); + expect(onSongUpdate.mock.invocationCallOrder[0]).toBeLessThan( + mockRemoveScorePdf.mock.invocationCallOrder[0] + ); + }); +}); From 48d77841da464e8b21d2a998b58f13ee5d23311d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 07:33:34 +0900 Subject: [PATCH 059/166] fix(score): order attachment lifecycle behind project persistence --- apps/desktop/src/features/score/ScoreView.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 72732450f..d3b610ff9 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -18,8 +18,12 @@ export interface ScoreViewProps { * disabled without it. */ projectId: string | null; - /** Callback receiving the song with updated `scoreAttachments` metadata. */ - onSongUpdate: (song: RehearsalSong) => void; + /** + * Commit updated song metadata. Async owners may return `false` when the + * project snapshot was not durably accepted; legacy synchronous owners may + * return `void`, which remains an accepted update for backward compatibility. + */ + onSongUpdate: (song: RehearsalSong) => void | boolean | Promise; } /** @@ -77,9 +81,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { }; /** - * Attach a new score PDF via the native picker and open it. The attach - * control is disabled while `isAttaching`, so overlapping attaches cannot be - * started; the active project id is supplied by the enabled control. + * Attach a new score PDF via the native picker and open it only after the + * owning project metadata accepts the attachment. A rejected async metadata + * commit deliberately leaves the already-published PDF as a recovery + * candidate rather than deleting buyer bytes without lifecycle authority. */ const handleAttach = async (activeProjectId: string) => { setError(null); @@ -87,16 +92,28 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { try { const result = await attachScorePdf(activeProjectId, song.id); const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName }; - onSongUpdate({ ...song, scoreAttachments: [...attachments, attachment] }); - setIsAttaching(false); + const accepted = await onSongUpdate({ + ...song, + scoreAttachments: [...attachments, attachment] + }); + if (accepted === false) { + return; + } await openAttachment(activeProjectId, attachment); } catch (attachError) { - setIsAttaching(false); setError(bridgeErrorDetail(attachError, t("scoreAttachFailed"))); + } finally { + setIsAttaching(false); } }; - /** Remove an attachment after confirmation (metadata and stored copy). */ + /** + * Detach metadata before destructive byte deletion. If the project owner + * rejects the metadata commit, the stored PDF remains intact and referenced. + * Once metadata is accepted, a later storage-delete failure can leave an + * unreferenced recovery/cleanup candidate but cannot create a durable project + * reference to bytes that this interaction already deleted. + */ const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { const confirmed = window.confirm( t("scoreRemoveConfirm").replace("{fileName}", attachment.fileName) @@ -106,17 +123,20 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { } setError(null); try { - await removeScorePdf(activeProjectId, attachment.id); - onSongUpdate({ + const accepted = await onSongUpdate({ ...song, scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) }); + if (accepted === false) { + return; + } if (selected?.id === attachment.id) { readRequestRef.current += 1; setSelected(null); setPdfBytes(null); setIsOpening(false); } + await removeScorePdf(activeProjectId, attachment.id); } catch (removeError) { setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); } From 26e829241433cefc979092f2854dfd1096f0f781 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 07:34:44 +0900 Subject: [PATCH 060/166] docs(score): trace metadata-first detach ordering --- .../score-attachment-publication.md | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 586bba7f2..2582cf1e0 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -57,6 +57,16 @@ This lease prevents another process that uses the same current Score Storage con `remove_score_pdf_attachment` then owns final object deletion. Windows marks the exact opened object for deletion with `FileDispositionInfo`. Unix pins the parent directory, opens the basename with `openat(..., O_NOFOLLOW)`, rechecks device/inode and then calls `unlinkat`; the final basename race remains explicit. +## UI/application lifecycle ordering + +The Score view is the application-service boundary that coordinates buyer-visible attachment metadata with Score Storage bytes; it does not move either bounded context's lower-level authority into React. + +Attachment is necessarily a two-step operation: publish and verify the PDF first, then ask the owning project mutation callback to accept the new attachment metadata. The callback may be asynchronous. A return value of `false` means the owning Project Persistence path did not durably accept that metadata. In that case ScoreView does **not** open or otherwise present the newly published PDF as an accepted attachment, and it deliberately does not delete those bytes: the PDF is a recovery candidate until #970 supplies the durable attachment lifecycle/reconciliation contract. Legacy synchronous callbacks returning `void` remain accepted for the pre-#970 stack. + +Detach uses the opposite safety ordering because deletion is destructive. ScoreView first asks the project owner to accept metadata without the attachment. Only after that update is accepted does it call the Score Storage deletion boundary. A rejected metadata update therefore leaves the still-referenced PDF bytes intact. If metadata removal succeeds but byte deletion later fails, the project no longer holds a broken reference to missing bytes; the remaining file is an unreferenced cleanup/recovery candidate and the storage error is surfaced. This is intentionally preferable to deleting buyer bytes first and then discovering that durable metadata still references them. + +`ScoreView.persistenceOutcome.test.tsx` covers three application-level invariants: rejected attachment metadata does not trigger PDF read/open acceptance; rejected detach metadata never calls destructive removal; accepted detach invokes metadata acceptance before Score Storage deletion. This is ordering evidence only. It does not claim an atomic filesystem-plus-project transaction, restart reconciliation, multi-window serialization, or packaged crash coverage. + ## Successful-publication restart/readback `score_pdf_restart_readback` publishes through the production crate-root boundary, starts a fresh native test process, resolves the stored score through `resolve_existing_score_pdf`, reads it through `read_validated_score_pdf` and requires exact bytes. The success path also requires zero `.score-*.stage` aliases after publication. @@ -92,25 +102,27 @@ Blind `.score-*.stage` glob deletion is rejected because a live concurrent write Deleting a matching destination while recovering a stage is rejected because filesystem publication is not yet transactionally coupled to the buyer-visible attachment metadata lifecycle. A stage-plus-destination state is ambiguous and is preserved for the later lifecycle/recovery vertical. +Deleting the stored PDF before the owning project accepts attachment removal is rejected because a failed metadata commit can leave a durable project pointing at bytes that the same interaction already destroyed. Detach therefore commits metadata first and treats a later delete failure as an orphan-cleanup problem instead of a broken-reference problem. + `std::fs::copy`, create-then-`chmod`, process-wide `umask` mutation, overwrite publication, length-only destination checks, pathname hashing as primary identity and a second pathname `stat` before deletion remain rejected for the reasons encoded in the corresponding REDs: each leaves visibility, mutation, clobber or pathname/object identity gaps. ## Security Notes **Untrusted input.** Selected PDF bytes/path and pre-existing destination, stage, lock and retention names are untrusted. No selected PDF bytes or absolute buyer path are emitted in ordinary errors. -**Trust boundaries.** OS-selected source → source admission → Score Storage workspace lease → reserved-stage recovery → descriptor-bounded private stage → no-clobber/object-identity-attested attachment. Removal remains validated score id → existing workspace precondition → exact child resolution → OS-object deletion. #970 still owns broader app-owned workspace ancestry/link authority. +**Trust boundaries.** OS-selected source → source admission → Score Storage workspace lease → reserved-stage recovery → descriptor-bounded private stage → no-clobber/object-identity-attested attachment. Removal remains project-metadata acceptance → validated score id → existing workspace precondition → exact child resolution → OS-object deletion. #970 still owns broader app-owned workspace ancestry/link and durable project-metadata authority. -**Safe failure.** Oversize, growth/truncation, wrong magic, duplicate destination, copy/sync failure, suspicious reserved stage, reparse/symlink/non-regular object, identity mismatch, lease acquisition failure, missing/indeterminate workspace, unsafe resolution and stage-plus-destination ambiguity fail closed. Recovery does not turn ambiguous lifecycle state into deletion. +**Safe failure.** Oversize, growth/truncation, wrong magic, duplicate destination, copy/sync failure, suspicious reserved stage, reparse/symlink/non-regular object, identity mismatch, lease acquisition failure, missing/indeterminate workspace, unsafe resolution and stage-plus-destination ambiguity fail closed. Rejected project-metadata acceptance does not trigger destructive score deletion. Recovery does not turn ambiguous lifecycle state into deletion. **Privacy.** Unix stage and lock creation request `0600`. Windows deliberately consumes the parent DACL contract. The lock file contains no PDF payload. The native interruption fixture uses generated test PDF bytes only. -**Test points.** Native tests cover valid/no-clobber publication, same-length destination replacement, Unix permissive-umask privacy, Windows parent-DACL inheritance, source growth/truncation, Tauri wiring, Windows handle-bound cleanup/delete replacement cases, Unix pre-`unlinkat` replacement, removal classification, successful fresh-process readback, reserved-stage namespace parsing, and process-terminated stage-only recovery before the next production publication. +**Test points.** Native tests cover valid/no-clobber publication, same-length destination replacement, Unix permissive-umask privacy, Windows parent-DACL inheritance, source growth/truncation, Tauri wiring, Windows handle-bound cleanup/delete replacement cases, Unix pre-`unlinkat` replacement, removal classification, successful fresh-process readback, reserved-stage namespace parsing, and process-terminated stage-only recovery before the next production publication. Frontend ordering tests cover rejected attach metadata, rejected detach metadata and metadata-before-delete ordering. ## Remaining risk / claim boundary -Stage-only process-kill recovery is now implemented under the current Score Storage lease contract. Still open are interruption after a destination has been hard-linked, explicit cancellation, disk-full, permission failure, power-loss durability, detach/project-delete/recovery rollback semantics, packaged-app fault evidence, and compatibility with an older concurrently running build that does not acquire the lease. +Stage-only process-kill recovery is now implemented under the current Score Storage lease contract. UI/application ordering now prevents a rejected metadata detach from deleting referenced bytes, and it refuses to present an asynchronously rejected attachment as accepted. Still open are interruption after a destination has been hard-linked, restart reconciliation of a published attachment whose metadata commit never became durable, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback semantics, packaged-app fault evidence, and compatibility with an older concurrently running build that does not acquire the lease. -The Windows parent-DACL acceptance must be rerun after #970 workspace authority integrates. Parent-directory durability and project lifecycle remain #970 concerns; Score Storage must reconcile without copying that implementation. +The Windows parent-DACL acceptance must be rerun after #970 workspace authority integrates. Parent-directory durability and project lifecycle remain #970 concerns; Score Storage must reconcile without copying that implementation. The `void` callback compatibility path remains only for the pre-#970 synchronous owner and is not evidence of durable metadata acceptance. A successful final publication attests object identity at the covered boundary but does not make the pathname immutable afterward. Windows cleanup/delete is object-bound for the tested contract. Unix cleanup/delete retains documented final basename races. A removal `Ok(None)` remains an observed absence, not an atomic reservation. @@ -124,7 +136,7 @@ Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase. Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info -Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle +Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-setfileinformationbyhandle Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea From 5944ecf10254ec911a97b18acc0c2cb7cc94a393 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 07:35:52 +0900 Subject: [PATCH 061/166] docs(score): fix Win32 disposition reference --- docs/traceability/score-attachment-publication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 2582cf1e0..80f5c2dde 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -136,13 +136,13 @@ Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase. Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info -Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-setfileinformationbyhandle +Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules -Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow +Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-getnamedsecurityinfow The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html From bbfa0b5693590f12ef29d4a9545426f34ed558c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 07:36:22 +0900 Subject: [PATCH 062/166] docs(score): restore exact ACL API reference --- docs/traceability/score-attachment-publication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 80f5c2dde..91edabacf 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -142,7 +142,7 @@ Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https:// Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules -Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-getnamedsecurityinfow +Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html From 7979ea23c2b3733c4175f7c969aa196fc6b672c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:05:46 +0900 Subject: [PATCH 063/166] test(score): require restart recovery inventory --- .../tests/score_pdf_recovery_inventory.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_recovery_inventory.rs diff --git a/apps/desktop/core/tests/score_pdf_recovery_inventory.rs b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs new file mode 100644 index 000000000..a9590ca66 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs @@ -0,0 +1,100 @@ +use bandscope_desktop_core::{inventory_published_score_pdf_ids, publish_score_pdf_attachment}; +use std::{ + fs, + path::PathBuf, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +const CHILD_ENV: &str = "BANDSCOPE_SCORE_INVENTORY_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_INVENTORY_ROOT"; +const SCORE_ID_A: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +const SCORE_ID_B: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355f"; + +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-score-inventory-{name}-{suffix}")) +} + +fn publish_fixture(root: &PathBuf, score_id: &str, label: &str) { + let source = root.join(format!("selected-{label}.pdf")); + fs::write(&source, format!("%PDF-1.7\n{label}").as_bytes()) + .expect("score source fixture should be written"); + publish_score_pdf_attachment(&source, root, score_id) + .expect("score fixture should publish through the production owner"); +} + +fn inventory_child() { + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("inventory child should receive score workspace"), + ); + let ids = inventory_published_score_pdf_ids(&root) + .expect("fresh process should inventory published Score Storage objects"); + assert_eq!(ids, vec![SCORE_ID_A.to_string(), SCORE_ID_B.to_string()]); +} + +#[test] +fn published_score_inventory_survives_process_restart() { + if std::env::var_os(CHILD_ENV).is_some() { + inventory_child(); + return; + } + + let root = unique_test_dir("restart"); + fs::create_dir_all(&root).expect("score workspace should be created"); + publish_fixture(&root, SCORE_ID_B, "second"); + publish_fixture(&root, SCORE_ID_A, "first"); + fs::write(root.join("notes.pdf"), b"%PDF-1.7\nunowned") + .expect("unowned pdf fixture should be written"); + + let status = Command::new(std::env::current_exe().expect("test binary path should resolve")) + .arg("--exact") + .arg("published_score_inventory_survives_process_restart") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env(ROOT_ENV, &root) + .status() + .expect("inventory child should launch"); + assert!(status.success(), "fresh-process score inventory must succeed"); + + fs::remove_dir_all(root).expect("restart inventory fixture should be removable"); +} + +#[test] +fn inventory_recovers_abandoned_stage_only_before_listing() { + let root = unique_test_dir("stage-only"); + fs::create_dir_all(&root).expect("score workspace should be created"); + publish_fixture(&root, SCORE_ID_A, "published"); + let stage = root.join(format!(".score-{SCORE_ID_B}.stage")); + fs::write(&stage, b"%PDF-1.7\nabandoned") + .expect("abandoned stage fixture should be written"); + + let ids = inventory_published_score_pdf_ids(&root) + .expect("inventory should recover process-abandoned stage-only state"); + + assert_eq!(ids, vec![SCORE_ID_A.to_string()]); + assert!(!stage.exists(), "stage-only abandoned bytes should be retired before inventory"); + fs::remove_dir_all(root).expect("stage-only fixture should be removable"); +} + +#[test] +fn inventory_preserves_stage_plus_destination_ambiguity() { + let root = unique_test_dir("ambiguous"); + fs::create_dir_all(&root).expect("score workspace should be created"); + let stage = root.join(format!(".score-{SCORE_ID_A}.stage")); + let destination = root.join(format!("{SCORE_ID_A}.pdf")); + fs::write(&stage, b"%PDF-1.7\nstage").expect("stage fixture should be written"); + fs::write(&destination, b"%PDF-1.7\ndestination") + .expect("destination fixture should be written"); + + assert!( + inventory_published_score_pdf_ids(&root).is_err(), + "inventory must not guess lifecycle intent when stage and destination both exist" + ); + assert!(stage.exists()); + assert!(destination.exists()); + fs::remove_dir_all(root).expect("ambiguous fixture should be removable"); +} From 25fcd0a31e948c7caef68bcc17ce7e3813c21675 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:05:57 +0900 Subject: [PATCH 064/166] test(score): execute recovery inventory contract --- .github/workflows/score-storage-native.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 2371b6a7f..f258e4244 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -67,7 +67,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --lib score_recovery::tests:: - - name: Run Score Storage publication, retention, interruption, restart, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -75,4 +75,5 @@ jobs: --test score_pdf_retention_resolution --test score_pdf_interruption_recovery --test score_pdf_restart_readback - --test score_pdf_attachment_wiring + --test score_pdf_recovery_inventory + --test score_pdf_attachment_wiring \ No newline at end of file From a9acc39b2cbf06d3ed09d879ceea1b833ee10614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:06:35 +0900 Subject: [PATCH 065/166] fix(score): expose stable published-object inventory --- apps/desktop/core/src/score_recovery.rs | 61 ++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 2abbe9f5a..2133fe3d7 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -1,4 +1,7 @@ -use crate::{is_valid_score_id, score_storage, score_storage::remove_score_pdf_attachment}; +use crate::{ + is_valid_score_id, score_retention::resolve_existing_score_pdf, score_storage, + score_storage::remove_score_pdf_attachment, +}; use std::{ fs::{self, File, OpenOptions}, path::{Path, PathBuf}, @@ -126,6 +129,11 @@ fn reserved_stage_score_id(name: &str) -> Option<&str> { is_valid_score_id(score_id).then_some(score_id) } +fn published_score_id(name: &str) -> Option<&str> { + let score_id = name.strip_suffix(".pdf")?; + is_valid_score_id(score_id).then_some(score_id) +} + fn recover_abandoned_score_stages( scores_root: &Path, lease: &ScoreWorkspaceLease, @@ -178,6 +186,46 @@ fn recover_abandoned_score_stages( Ok(removed) } +/// Return a deterministic inventory of safely published Score Storage objects. +/// +/// The inventory is intentionally only byte/object truth. It does not claim +/// that a returned score id is referenced by durable project metadata, nor +/// whether an unreferenced object should be recovered or deleted. Project +/// Persistence owns that lifecycle decision. Before listing, the function +/// acquires the same cross-process lease as publication and performs the same +/// abandoned-stage recovery so a fresh process cannot report a workspace while +/// a current-contract writer is live or while stage-plus-destination state is +/// ambiguous. +/// +/// Security Notes: only exact `.pdf` names enter the owned object +/// inventory. Unrelated files are ignored rather than treated as Score Storage +/// objects. A matching owned name must resolve through the existing retention +/// boundary as a regular contained non-reparse object; suspicious matching +/// entries and ambiguous publication state fail closed. No filesystem path, +/// filename from the selected source, or PDF payload is returned. +pub fn inventory_published_score_pdf_ids(scores_root: &Path) -> Result, String> { + let lease = acquire_score_workspace_lease(scores_root)?; + recover_abandoned_score_stages(scores_root, &lease)?; + + let mut score_ids = Vec::new(); + let entries = fs::read_dir(scores_root).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + for entry in entries { + let entry = entry.map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(score_id) = published_score_id(name) else { + continue; + }; + resolve_existing_score_pdf(scores_root, score_id) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + score_ids.push(score_id.to_string()); + } + score_ids.sort_unstable(); + Ok(score_ids) +} + /// Publish one score attachment after recovering process-abandoned staging. /// /// The workspace lease spans recovery and the complete lower-level publication @@ -241,6 +289,17 @@ mod tests { assert_eq!(reserved_stage_score_id(".score-6fa459ea-ee8a-4ca4-894e-db77e160355e.tmp"), None); } + #[test] + fn published_score_name_requires_exact_score_uuid_shape() { + assert_eq!( + published_score_id("6fa459ea-ee8a-4ca4-894e-db77e160355e.pdf"), + Some(SCORE_ID) + ); + assert_eq!(published_score_id("notes.pdf"), None); + assert_eq!(published_score_id("../escape.pdf"), None); + assert_eq!(published_score_id("6fa459ea-ee8a-4ca4-894e-db77e160355e.PDF"), None); + } + #[test] fn workspace_lease_child() { if std::env::var_os(LEASE_CHILD_ENV).is_none() { From f7b31910fe203b42c1c595044a7e884946fe6b1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:06:41 +0900 Subject: [PATCH 066/166] fix(score): re-export recovery inventory contract --- apps/desktop/core/src/root.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 9a587d350..6bc335558 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -14,6 +14,6 @@ mod score_storage; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; -pub use score_recovery::publish_score_pdf_attachment; +pub use score_recovery::{inventory_published_score_pdf_ids, publish_score_pdf_attachment}; pub use score_retention::resolve_score_pdf_for_removal; -pub use score_storage::remove_score_pdf_attachment; +pub use score_storage::remove_score_pdf_attachment; \ No newline at end of file From 1765fa075fa10106228566d75f5727ead8ceb405 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:07:29 +0900 Subject: [PATCH 067/166] fix(score): resolve inventory through canonical reader owner --- apps/desktop/core/src/score_recovery.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 2133fe3d7..1c722c8c6 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -1,5 +1,5 @@ use crate::{ - is_valid_score_id, score_retention::resolve_existing_score_pdf, score_storage, + is_valid_score_id, resolve_existing_score_pdf, score_storage, score_storage::remove_score_pdf_attachment, }; use std::{ @@ -199,10 +199,10 @@ fn recover_abandoned_score_stages( /// /// Security Notes: only exact `.pdf` names enter the owned object /// inventory. Unrelated files are ignored rather than treated as Score Storage -/// objects. A matching owned name must resolve through the existing retention -/// boundary as a regular contained non-reparse object; suspicious matching -/// entries and ambiguous publication state fail closed. No filesystem path, -/// filename from the selected source, or PDF payload is returned. +/// objects. A matching owned name must resolve through the existing read-time +/// containment boundary as a regular contained non-symlink object; suspicious +/// matching entries and ambiguous publication state fail closed. No filesystem +/// path, filename from the selected source, or PDF payload is returned. pub fn inventory_published_score_pdf_ids(scores_root: &Path) -> Result, String> { let lease = acquire_score_workspace_lease(scores_root)?; recover_abandoned_score_stages(scores_root, &lease)?; From 362d3359a49765d17b631a6f4e83fcace1fbc6de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:08:19 +0900 Subject: [PATCH 068/166] docs(score): trace restart object inventory boundary --- .../score-attachment-recovery-inventory.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/traceability/score-attachment-recovery-inventory.md diff --git a/docs/traceability/score-attachment-recovery-inventory.md b/docs/traceability/score-attachment-recovery-inventory.md new file mode 100644 index 000000000..ad7a571c6 --- /dev/null +++ b/docs/traceability/score-attachment-recovery-inventory.md @@ -0,0 +1,66 @@ +# Score attachment recovery inventory + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment +Related owner: Project Persistence #970 + +## Problem + +A score PDF can be durably published before its attachment metadata becomes durable in the project document. After a process restart, Project Persistence can only reconcile that window if Score Storage can report which score objects actually exist without guessing whether those objects are accepted project attachments. + +The pre-repair Score Storage API could publish, read and remove a score by known id, and could recover a stage-only artifact before the next publication. It had no restart-safe inventory contract. Project Persistence therefore had no narrow owner API from which to derive `published object - durable project reference` candidates. + +## Owner boundary + +Score Storage owns filesystem object truth only. `inventory_published_score_pdf_ids` returns validated score ids for safely published `.pdf` objects. It does **not** decide whether an id is referenced by durable project metadata, whether an unreferenced object came from interrupted attach versus failed cleanup after detach, or whether the buyer wants to recover or discard it. + +Project Persistence #970 remains responsible for durable project references and any later reconciliation decision. A future consumer must compare its own durable attachment references against this object inventory through an explicit application/ACL boundary. Cross-service SQL, filesystem-path inference and source copying are not part of this contract. + +## RED and causal repair + +`7979ea23c2b3733c4175f7c969aa196fc6b672c5` added `score_pdf_recovery_inventory.rs`. The regression requires a fresh native process to rediscover two production-published score ids in deterministic order, requires stage-only abandonment to be recovered before inventory, and requires stage-plus-destination ambiguity to remain preserved and fail closed. `25fcd0a31e948c7caef68bcc17ce7e3813c21675` put that regression in the owned macOS/Windows Score Storage workflow. At that source identity the public inventory symbol did not exist, so the new target is a deterministic source RED; no hosted RED is claimed unless a terminal run for that exact test-only lineage is available. + +`a9acc39b2cbf06d3ed09d879ceea1b833ee10614` added the inventory implementation. Self-review then found that its containment resolver import named the wrong sibling owner. `1765fa075fa10106228566d75f5727ead8ceb405` repaired that dependency to the canonical crate-root `resolve_existing_score_pdf` boundary rather than duplicating read-time path validation. + +The public crate-root API is re-exported by `f7b31910fe203b42c1c595044a7e884946fe6b1d` and retained at the current descendant head. + +## Inventory contract + +Before returning any object ids, the owner: + +- validates and acquires the existing cross-process Score Storage workspace lease; +- runs the existing abandoned-stage recovery while that lease is held; +- fails closed if stage-plus-destination state is ambiguous; +- enumerates only exact `.pdf` names; +- resolves every matching owned name through the existing bounded containment/symlink guard; +- ignores unrelated filenames instead of broadening Score Storage ownership; +- sorts returned ids deterministically; +- returns ids only, never local paths, selected-source filenames or PDF payload bytes. + +The lease makes the inventory a stable observation with respect to another current-contract writer. It does not make older builds that never take the lease compatible. + +## Alternatives rejected + +Scanning the scores directory from React is rejected because it exports filesystem authority across the native boundary and duplicates Score Storage validation. Treating every `*.pdf` as owned is rejected because unrelated or attacker-planted names would broaden the deletion/recovery namespace. Returning absolute paths is rejected because consumers need object identity, not filesystem authority. Automatically attaching or deleting unreferenced ids is rejected because Score Storage cannot infer Project Persistence lifecycle intent. + +A mutable sidecar lifecycle ledger is not introduced in this slice. It would create a second persistence protocol whose crash semantics must themselves be reconciled with the project document. The narrower object inventory is sufficient to let the canonical owners meet at an ACL later without pretending that byte existence equals project acceptance. + +## Security Notes + +**Untrusted input.** Every directory entry under the scores workspace is untrusted. Only an exact valid score-id filename enters the owned inventory. + +**Trust boundary.** App-owned score workspace → OS-held Score Storage lease → abandoned-stage recovery → exact owned-name parsing → existing native containment resolver → score-id-only inventory. + +**Safe failure.** Lease contention, unreadable directory state, suspicious matching objects, unsafe resolution and stage-plus-destination ambiguity fail closed. The inventory never converts ambiguity into delete or project metadata mutation. + +**Privacy.** The API exposes only BandScope-generated score ids. It does not return local paths, original selected filenames or PDF bytes. + +## Test points + +`score_pdf_recovery_inventory` covers fresh-process rediscovery of production-published objects, deterministic ordering, ignoring an unrelated `notes.pdf`, stage-only cleanup before listing, and preservation/failure for stage-plus-destination ambiguity. Existing Score Storage unit and publication/retention/interruption/restart/wiring regressions remain in the same owner workflow. + +## Claim boundary and next step + +This slice creates the Score Storage half of restart reconciliation. It does **not** identify which returned ids are referenced by the durable project document, recover attachment metadata, infer original display filenames, delete unreferenced objects, or provide buyer-visible recovery UX. Those decisions require #970 Project Persistence integration and a narrow consumer contract that compares durable project references against this object inventory. + +Until that consumer exists, `PDF durable -> metadata not durable` remains an open commercial gap. The correct next step is to integrate the protected Project Persistence owner, pass durable attachment ids rather than filesystem paths across the ACL, and expose ambiguous candidates to a buyer-visible recovery decision without automatic attach/delete. \ No newline at end of file From 4c72d9eaa48cf775db5003724a02cc88a76aaae3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 15:08:30 +0900 Subject: [PATCH 069/166] ci(score): track recovery inventory traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index f258e4244..394907ddd 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -16,6 +16,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" + - "docs/traceability/score-attachment-recovery-inventory.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -32,6 +33,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" + - "docs/traceability/score-attachment-recovery-inventory.md" - ".github/workflows/score-storage-native.yml" permissions: From 4d8d6c10761235c64ce5c5f70e4a0567ad5d4383 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:07:31 +0900 Subject: [PATCH 070/166] test(score): reject stale recovery receipt after same-id republish --- .../score_pdf_recovery_object_receipt.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs diff --git a/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs new file mode 100644 index 000000000..01a08b70c --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs @@ -0,0 +1,86 @@ +use bandscope_desktop_core::{ + inventory_published_score_pdf_receipts, publish_score_pdf_attachment, + remove_score_pdf_attachment, remove_score_pdf_attachment_if_receipt_matches, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SCORE_ID: &str = "018f2a2b-9c1d-7a40-8b31-6f7cbbf05001"; + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "bandscope-score-recovery-receipt-{label}-{}-{nonce}", + std::process::id() + )) +} + +fn write_pdf(path: &Path, marker: &[u8]) { + let mut bytes = b"%PDF-1.7\n".to_vec(); + bytes.extend_from_slice(marker); + bytes.extend_from_slice(b"\n%%EOF\n"); + fs::write(path, bytes).expect("fixture PDF should be writable"); +} + +#[test] +fn stale_receipt_cannot_remove_same_id_republished_bytes() { + let root = unique_temp_dir("aba"); + let scores_root = root.join("scores"); + let source_a = root.join("a.pdf"); + let source_b = root.join("b.pdf"); + fs::create_dir_all(&root).expect("fixture root should be creatable"); + write_pdf(&source_a, b"first durable score bytes"); + write_pdf(&source_b, b"replacement durable score bytes"); + + publish_score_pdf_attachment(&source_a, &scores_root, SCORE_ID) + .expect("first production publication should succeed"); + let receipt_a = inventory_published_score_pdf_receipts(&scores_root) + .expect("first inventory should succeed") + .into_iter() + .find(|receipt| receipt.score_id() == SCORE_ID) + .expect("first published object should have a receipt"); + + assert!( + remove_score_pdf_attachment(&scores_root, SCORE_ID) + .expect("explicit owner removal should remove first object") + ); + publish_score_pdf_attachment(&source_b, &scores_root, SCORE_ID) + .expect("same id may currently be republished with different bytes"); + + let receipt_b = inventory_published_score_pdf_receipts(&scores_root) + .expect("second inventory should succeed") + .into_iter() + .find(|receipt| receipt.score_id() == SCORE_ID) + .expect("replacement object should have a receipt"); + assert_ne!( + receipt_a.content_sha256(), + receipt_b.content_sha256(), + "replacement bytes must produce a new object receipt" + ); + + let stale_result = remove_score_pdf_attachment_if_receipt_matches(&scores_root, &receipt_a) + .expect("stale object identity should be a safe non-removal outcome"); + assert!( + !stale_result, + "a stale receipt must not remove replacement bytes published under the same score id" + ); + assert!( + scores_root.join(format!("{SCORE_ID}.pdf")).exists(), + "replacement bytes must remain after stale recovery intent" + ); + + assert!( + remove_score_pdf_attachment_if_receipt_matches(&scores_root, &receipt_b) + .expect("fresh receipt should authorize deletion of the exact replacement object") + ); + assert!( + !scores_root.join(format!("{SCORE_ID}.pdf")).exists(), + "fresh receipt deletion should remove the exact current object" + ); + + fs::remove_dir_all(&root).expect("fixture root should be removable"); +} From c1a4c78da0f4d76c67967dc2d05df5019599d511 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:08:23 +0900 Subject: [PATCH 071/166] test(score): isolate ABA receipt regression --- .../desktop/core/tests/score_pdf_recovery_object_receipt.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs index 01a08b70c..11be99847 100644 --- a/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs +++ b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs @@ -44,10 +44,8 @@ fn stale_receipt_cannot_remove_same_id_republished_bytes() { .find(|receipt| receipt.score_id() == SCORE_ID) .expect("first published object should have a receipt"); - assert!( - remove_score_pdf_attachment(&scores_root, SCORE_ID) - .expect("explicit owner removal should remove first object") - ); + remove_score_pdf_attachment(&scores_root.join(format!("{SCORE_ID}.pdf"))) + .expect("explicit owner removal should remove first object"); publish_score_pdf_attachment(&source_b, &scores_root, SCORE_ID) .expect("same id may currently be republished with different bytes"); From 843704ba51b78da16dfcf00682ff2ee051eb6219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:09:03 +0900 Subject: [PATCH 072/166] refactor(core): adopt shared content identity kernel --- apps/desktop/core/src/content_sha256.rs | 293 ++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 apps/desktop/core/src/content_sha256.rs 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" + ); + } +} From 3dc2cf768629973676e9cfbac4b6a103f170aa3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:09:20 +0900 Subject: [PATCH 073/166] feat(score): expose content-bound recovery receipts --- apps/desktop/core/src/root.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 6bc335558..ec72517b0 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -7,13 +7,19 @@ #[path = "lib.rs"] mod runtime_core; +mod content_sha256; mod score_pdf; mod score_recovery; mod score_retention; mod score_storage; +pub use content_sha256::sha256_hex_reader; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; -pub use score_recovery::{inventory_published_score_pdf_ids, publish_score_pdf_attachment}; +pub use score_recovery::{ + inventory_published_score_pdf_ids, inventory_published_score_pdf_receipts, + publish_score_pdf_attachment, remove_score_pdf_attachment_if_receipt_matches, + PublishedScorePdfReceipt, +}; pub use score_retention::resolve_score_pdf_for_removal; pub use score_storage::remove_score_pdf_attachment; \ No newline at end of file From 4b751690daffcceeaa1cfcc310d75b1516a895a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:10:12 +0900 Subject: [PATCH 074/166] fix(score): bind recovery mutation to content receipt --- apps/desktop/core/src/score_recovery.rs | 155 +++++++++++++++++++++--- 1 file changed, 137 insertions(+), 18 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 1c722c8c6..9112d5706 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -1,9 +1,10 @@ use crate::{ - is_valid_score_id, resolve_existing_score_pdf, score_storage, - score_storage::remove_score_pdf_attachment, + is_valid_score_id, read_validated_score_pdf, resolve_existing_score_pdf, score_storage, + score_storage::remove_score_pdf_attachment, sha256_hex_reader, }; use std::{ fs::{self, File, OpenOptions}, + io::Cursor, path::{Path, PathBuf}, }; @@ -11,6 +12,29 @@ const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; const SCORE_RECOVERY_ERROR: &str = "Could not recover the score workspace."; const SCORE_WORKSPACE_LOCK: &str = ".score-storage.lock"; +/// Opaque, path-free identity for one validated published Score Storage object. +/// +/// The receipt binds the logical score id to the SHA-256 of the bounded PDF bytes +/// observed while the Score Storage workspace lease is held. It is content +/// identity for stale-intent detection, not an authenticity or provenance claim. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishedScorePdfReceipt { + score_id: String, + content_sha256: String, +} + +impl PublishedScorePdfReceipt { + /// Return the validated BandScope score id bound into this receipt. + pub fn score_id(&self) -> &str { + &self.score_id + } + + /// Return the canonical lowercase SHA-256 bound into this receipt. + pub fn content_sha256(&self) -> &str { + &self.content_sha256 + } +} + /// Cross-process lease for Score Storage mutation and recovery. /// /// The lease file is intentionally persistent and contains no payload. Unix @@ -186,6 +210,52 @@ fn recover_abandoned_score_stages( Ok(removed) } +fn inventory_published_score_pdf_ids_under_lease( + scores_root: &Path, + lease: &ScoreWorkspaceLease, +) -> Result, String> { + if lease.root != scores_root { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + + let mut score_ids = Vec::new(); + let entries = fs::read_dir(scores_root).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + for entry in entries { + let entry = entry.map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(score_id) = published_score_id(name) else { + continue; + }; + resolve_existing_score_pdf(scores_root, score_id) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + score_ids.push(score_id.to_string()); + } + score_ids.sort_unstable(); + Ok(score_ids) +} + +fn receipt_for_score_id( + scores_root: &Path, + score_id: &str, + lease: &ScoreWorkspaceLease, +) -> Result { + if lease.root != scores_root || !is_valid_score_id(score_id) { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + let path = resolve_existing_score_pdf(scores_root, score_id) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let bytes = read_validated_score_pdf(&path).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let content_sha256 = sha256_hex_reader(Cursor::new(bytes)) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + Ok(PublishedScorePdfReceipt { + score_id: score_id.to_string(), + content_sha256, + }) +} + /// Return a deterministic inventory of safely published Score Storage objects. /// /// The inventory is intentionally only byte/object truth. It does not claim @@ -206,24 +276,73 @@ fn recover_abandoned_score_stages( pub fn inventory_published_score_pdf_ids(scores_root: &Path) -> Result, String> { let lease = acquire_score_workspace_lease(scores_root)?; recover_abandoned_score_stages(scores_root, &lease)?; + inventory_published_score_pdf_ids_under_lease(scores_root, &lease) +} - let mut score_ids = Vec::new(); - let entries = fs::read_dir(scores_root).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; - for entry in entries { - let entry = entry.map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - let Some(score_id) = published_score_id(name) else { - continue; - }; - resolve_existing_score_pdf(scores_root, score_id) - .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; - score_ids.push(score_id.to_string()); +/// Return content-bound receipts for safely published Score Storage objects. +/// +/// Each receipt binds one validated score id to the SHA-256 of the current +/// bounded PDF bytes while the existing cross-process workspace lease is held. +/// The receipt is deliberately path-free and exposes neither the selected source +/// filename nor PDF bytes. It gives recovery orchestration an object identity +/// that changes when the same score id is removed and later republished with +/// different bytes. +/// +/// Security Notes: discovery, abandoned-stage recovery, containment validation, +/// bounded PDF validation and hashing all occur under the Score Storage lease. +/// The digest is an equality receipt only; it is not a signature, authenticity +/// proof or durable lifecycle decision. +pub fn inventory_published_score_pdf_receipts( + scores_root: &Path, +) -> Result, String> { + let lease = acquire_score_workspace_lease(scores_root)?; + recover_abandoned_score_stages(scores_root, &lease)?; + let score_ids = inventory_published_score_pdf_ids_under_lease(scores_root, &lease)?; + score_ids + .iter() + .map(|score_id| receipt_for_score_id(scores_root, score_id, &lease)) + .collect() +} + +/// Remove a published score only when a fresh content receipt still matches. +/// +/// The workspace lease spans abandoned-stage recovery, current-object receipt +/// calculation, equality comparison and identity-safe deletion. This closes the +/// same-id ABA window for recovery `Discard`: a decision authorized for object A +/// cannot delete replacement object B merely because B reused the same score id. +/// A missing object or a content mismatch is a safe `Ok(false)` non-removal; +/// unsafe or indeterminate filesystem state remains an error. +/// +/// Security Notes: the receipt is path-free, and errors expose neither paths nor +/// bytes. The lower-level remover still performs its native identity checks +/// immediately before deletion. This function does not decide *whether* a score +/// should be discarded; it only enforces object freshness for an already +/// authorized Score Storage mutation. +pub fn remove_score_pdf_attachment_if_receipt_matches( + scores_root: &Path, + receipt: &PublishedScorePdfReceipt, +) -> Result { + if !is_valid_score_id(receipt.score_id()) { + return Err(SCORE_RECOVERY_ERROR.to_string()); } - score_ids.sort_unstable(); - Ok(score_ids) + + let lease = acquire_score_workspace_lease(scores_root)?; + recover_abandoned_score_stages(scores_root, &lease)?; + let path = scores_root.join(format!("{}.pdf", receipt.score_id())); + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), + } + + let current = receipt_for_score_id(scores_root, receipt.score_id(), &lease)?; + if current.content_sha256 != receipt.content_sha256 { + return Ok(false); + } + + score_storage::remove_score_pdf_attachment(&path) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + Ok(true) } /// Publish one score attachment after recovering process-abandoned staging. From 000dcd4c8815340f97c849380ee9f6788f98e203 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:10:27 +0900 Subject: [PATCH 075/166] test(core): retain shared content identity vectors --- .../core/tests/content_sha256_shared_kernel.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/desktop/core/tests/content_sha256_shared_kernel.rs diff --git a/apps/desktop/core/tests/content_sha256_shared_kernel.rs b/apps/desktop/core/tests/content_sha256_shared_kernel.rs new file mode 100644 index 000000000..8c1f92ae9 --- /dev/null +++ b/apps/desktop/core/tests/content_sha256_shared_kernel.rs @@ -0,0 +1,12 @@ +use bandscope_desktop_core::sha256_hex_reader; +use std::io::Cursor; + +#[test] +fn shared_sha256_reader_matches_the_fips_180_4_abc_vector() { + let digest = sha256_hex_reader(Cursor::new(b"abc")) + .expect("in-memory FIPS 180-4 fixture should be readable"); + assert_eq!( + digest, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); +} From cec2a620733d4647108e5597ccc94e9bda3b10bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:10:40 +0900 Subject: [PATCH 076/166] ci(score): own recovery content receipt regression --- .github/workflows/score-storage-native.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 394907ddd..8066b9937 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -9,14 +9,17 @@ on: - "apps/desktop/core/Cargo.toml" - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/content_sha256.rs" - "apps/desktop/core/src/score_pdf.rs" - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" + - "docs/traceability/score-attachment-recovery-object-receipt.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -26,14 +29,17 @@ on: - "apps/desktop/core/Cargo.toml" - "apps/desktop/core/src/root.rs" - "apps/desktop/core/src/lib.rs" + - "apps/desktop/core/src/content_sha256.rs" - "apps/desktop/core/src/score_pdf.rs" - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" + - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" + - "docs/traceability/score-attachment-recovery-object-receipt.md" - ".github/workflows/score-storage-native.yml" permissions: @@ -69,7 +75,10 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --lib score_recovery::tests:: - - name: Run Score Storage publication, retention, interruption, restart, inventory, and wiring regressions + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --test content_sha256_shared_kernel + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -78,4 +87,5 @@ jobs: --test score_pdf_interruption_recovery --test score_pdf_restart_readback --test score_pdf_recovery_inventory + --test score_pdf_recovery_object_receipt --test score_pdf_attachment_wiring \ No newline at end of file From b252cac4abf91d2722a2f9e87e8cbf949dd4d41d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:11:03 +0900 Subject: [PATCH 077/166] docs(score): trace recovery object receipt boundary --- ...core-attachment-recovery-object-receipt.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/traceability/score-attachment-recovery-object-receipt.md diff --git a/docs/traceability/score-attachment-recovery-object-receipt.md b/docs/traceability/score-attachment-recovery-object-receipt.md new file mode 100644 index 000000000..9f2ee4063 --- /dev/null +++ b/docs/traceability/score-attachment-recovery-object-receipt.md @@ -0,0 +1,50 @@ +# Score attachment recovery object receipt + +## Problem + +The restart inventory introduced for #1239 deliberately returned validated `score_id` values only. That was sufficient for discovery but not for mutation authority. A score can be removed and another PDF can later be published under the same valid id. A recovery decision made for the first object would then have the same logical id as the replacement object even though the buyer bytes changed. Treating the id as durable object identity creates a same-id ABA window for recovery `Discard` and any later mutation that relies on stale inventory state. + +## Constraint and owner boundary + +Score Storage owns published PDF byte/object truth, its cross-process workspace lease, bounded PDF validation and native identity-safe deletion. Project Persistence owns durable project references and recovery intent. This change does not let Project Persistence scan the score directory, infer lifecycle state, or delete bytes directly. + +The receipt is therefore narrow and path-free: validated `score_id` plus lowercase SHA-256 of the current bounded PDF bytes. It exposes neither the app-private path, original selected filename nor PDF payload. SHA-256 is used only as content identity for equality; it is not a signature, authenticity proof, provenance claim or FIPS 140 validation claim. + +## Decision + +`inventory_published_score_pdf_receipts` acquires the existing Score Storage workspace lease, performs existing abandoned-stage recovery, enumerates only validated owned score names, validates the current PDF through the established bounded read contract and computes a content receipt before releasing the lease. + +`remove_score_pdf_attachment_if_receipt_matches` reacquires that same lease and keeps it across abandoned-stage recovery, current-object receipt calculation, equality comparison and the existing native identity-safe remover. A missing current object or different content receipt is a safe non-removal (`Ok(false)`); suspicious or indeterminate filesystem state fails closed. + +The existing id-only inventory remains available for discovery compatibility. Recovery mutation must use the receipt-bearing contract when object freshness matters. + +## Shared kernel adoption + +The SHA-256 implementation is the same generic `content_sha256` shared-kernel implementation already present on Project Persistence #970. This lane adopts that existing implementation rather than adding a second algorithm or dependency. When the owner stacks are reconciled, the identical shared-kernel delta must be consolidated rather than retained as parallel source copies. + +The implementation follows NIST FIPS 180-4 SHA-256 operations and includes published known-answer vectors. No new cryptographic dependency, network call or provider is introduced. + +## Regression and causal repair + +Source RED `4d8d6c10761235c64ce5c5f70e4a0567ad5d4383`, refined by `c1a4c78da0f4d76c67967dc2d05df5019599d511`, creates object A, inventories its receipt, removes A, republishes different bytes B under the same score id, then requires stale receipt A to preserve B while fresh receipt B can remove exactly B. The RED is source-level unless a terminal failing hosted run is independently observed; it is not promoted to hosted RED merely because the missing API would fail compilation. + +Causal repair is the receipt-bearing Score Storage contract on the descendant head: path-free receipt inventory plus lease-scoped compare-and-delete. The test intentionally uses the production publisher and production remover rather than generated arrays or a fake filesystem lifecycle. + +## Security notes + +- Untrusted state includes score workspace directory entries, PDF bytes and stale recovery decisions. +- The Score Storage workspace lease serializes current-contract writers from recovery through comparison and deletion. +- Exact score-name validation, existing containment checks, bounded PDF reads and existing native deletion identity checks remain in force. +- A digest mismatch never falls back to id-only deletion. +- Errors do not disclose buyer filesystem paths or PDF bytes. +- The receipt does not authorize a lifecycle decision by itself. Project Persistence/application orchestration must still prove that the candidate remains eligible and that the buyer chose the requested action. + +## Alternatives rejected + +Lifetime non-reuse tombstones were rejected for this slice because they would add a new durable lifecycle sidecar and compatibility/migration semantics solely to compensate for logical-id reuse. Filesystem inode/file-id alone was rejected because object identifiers can be reused and are platform-specific. Revalidating a receipt and then releasing the lease before deletion was rejected because it recreates a TOCTOU window. Hashing a path without the existing bounded/contained read boundary was rejected because it would create a second filesystem authority. + +## Remaining work + +The application recovery flow must consume fresh Score Storage receipts and active Project Persistence project identity immediately before Recover/Preserve/Discard execution. Recover still requires durable Project Persistence CAS acceptance before presentation as an accepted attachment. Discard must call the receipt-bound Score Storage mutation rather than id-only deletion. `missing_referenced_score_ids` remains a broken-attachment state, not cleanup authority. + +Protected integration, independent review, packaged process-kill/cancellation/disk-full/permission/power-loss evidence, Windows ACL revalidation, signing/notarization, provenance/reproducibility and immutable release/update rollback remain separate gates. From ee7995e222756aa04f6f1cd83ed2dd5e2d928afe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:14:02 +0900 Subject: [PATCH 078/166] test(score): create recovery workspace before publication --- apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs index 11be99847..65a0c22bb 100644 --- a/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs +++ b/apps/desktop/core/tests/score_pdf_recovery_object_receipt.rs @@ -32,7 +32,7 @@ fn stale_receipt_cannot_remove_same_id_republished_bytes() { let scores_root = root.join("scores"); let source_a = root.join("a.pdf"); let source_b = root.join("b.pdf"); - fs::create_dir_all(&root).expect("fixture root should be creatable"); + fs::create_dir_all(&scores_root).expect("score workspace should be creatable"); write_pdf(&source_a, b"first durable score bytes"); write_pdf(&source_b, b"replacement durable score bytes"); From 43fd4666eb92a9376d5253610753de7240596a54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 20:14:33 +0900 Subject: [PATCH 079/166] docs(score): record recovery fixture RCA --- .../score-attachment-recovery-object-receipt.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/traceability/score-attachment-recovery-object-receipt.md b/docs/traceability/score-attachment-recovery-object-receipt.md index 9f2ee4063..dce127a68 100644 --- a/docs/traceability/score-attachment-recovery-object-receipt.md +++ b/docs/traceability/score-attachment-recovery-object-receipt.md @@ -26,9 +26,11 @@ The implementation follows NIST FIPS 180-4 SHA-256 operations and includes publi ## Regression and causal repair -Source RED `4d8d6c10761235c64ce5c5f70e4a0567ad5d4383`, refined by `c1a4c78da0f4d76c67967dc2d05df5019599d511`, creates object A, inventories its receipt, removes A, republishes different bytes B under the same score id, then requires stale receipt A to preserve B while fresh receipt B can remove exactly B. The RED is source-level unless a terminal failing hosted run is independently observed; it is not promoted to hosted RED merely because the missing API would fail compilation. +Source RED `4d8d6c10761235c64ce5c5f70e4a0567ad5d4383`, refined by `c1a4c78da0f4d76c67967dc2d05df5019599d511`, creates object A, inventories its receipt, removes A, republishes different bytes B under the same score id, then requires stale receipt A to preserve B while fresh receipt B can remove exactly B. The RED is source-level unless a terminal failing hosted run independently reaches the intended stale-receipt assertion; missing API compilation alone is not promoted to hosted RED. -Causal repair is the receipt-bearing Score Storage contract on the descendant head: path-free receipt inventory plus lease-scoped compare-and-delete. The test intentionally uses the production publisher and production remover rather than generated arrays or a fake filesystem lifecycle. +Causal repair is the receipt-bearing Score Storage contract: path-free receipt inventory plus lease-scoped compare-and-delete. The test intentionally uses the production publisher and production remover rather than generated arrays or a fake filesystem lifecycle. + +Exact `b252cac4abf91d2722a2f9e87e8cbf949dd4d41d` produced terminal macOS and Windows owner failures before the intended ABA assertion. Both platforms passed Score Storage unit tests and the shared SHA-256 known-answer test, then the new integration fixture failed its first production publication with `Could not recover the score workspace.`. RCA showed the test created only the fixture parent while production admission correctly requires the app-owned `scores` workspace to pre-exist before acquiring its lease. `ee7995e222756aa04f6f1cd83ed2dd5e2d928afe` fixes only the fixture by creating `scores_root`; production admission was not weakened and the failed run is not evidence against the receipt contract. ## Security notes @@ -41,7 +43,7 @@ Causal repair is the receipt-bearing Score Storage contract on the descendant he ## Alternatives rejected -Lifetime non-reuse tombstones were rejected for this slice because they would add a new durable lifecycle sidecar and compatibility/migration semantics solely to compensate for logical-id reuse. Filesystem inode/file-id alone was rejected because object identifiers can be reused and are platform-specific. Revalidating a receipt and then releasing the lease before deletion was rejected because it recreates a TOCTOU window. Hashing a path without the existing bounded/contained read boundary was rejected because it would create a second filesystem authority. +Lifetime non-reuse tombstones were rejected for this slice because they would add a new durable lifecycle sidecar and compatibility/migration semantics solely to compensate for logical-id reuse. Filesystem inode/file-id alone was rejected because object identifiers can be reused and are platform-specific. Revalidating a receipt and then releasing the lease before deletion was rejected because it recreates a TOCTOU window. Hashing a path without the existing bounded/contained read boundary was rejected because it would create a second filesystem authority. Weakening production admission so tests may publish into a missing workspace was also rejected; app-owned workspace creation belongs to its existing orchestration boundary. ## Remaining work From 1ce4bb86864ef845d39f7cb80614a2b686213499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:04:54 +0900 Subject: [PATCH 080/166] test(score): reproduce post-link interruption recovery --- .../tests/score_pdf_interruption_recovery.rs | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_interruption_recovery.rs b/apps/desktop/core/tests/score_pdf_interruption_recovery.rs index 135b360ec..76ff24ae0 100644 --- a/apps/desktop/core/tests/score_pdf_interruption_recovery.rs +++ b/apps/desktop/core/tests/score_pdf_interruption_recovery.rs @@ -1,4 +1,6 @@ -use bandscope_desktop_core::publish_score_pdf_attachment; +use bandscope_desktop_core::{ + inventory_published_score_pdf_receipts, publish_score_pdf_attachment, +}; use std::{ fs::OpenOptions, io::Write, @@ -9,14 +11,17 @@ use std::{ }; const ABANDONED_SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +const POST_LINK_SCORE_ID: &str = "018f2a2b-9c1d-7a40-8b31-6f7cbbf05001"; const NEXT_SCORE_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; const CHILD_ENV: &str = "BANDSCOPE_SCORE_INTERRUPTION_CHILD"; const ROOT_ENV: &str = "BANDSCOPE_SCORE_INTERRUPTION_ROOT"; +const POST_LINK_CHILD_ENV: &str = "BANDSCOPE_SCORE_POST_LINK_CHILD"; +const POST_LINK_ROOT_ENV: &str = "BANDSCOPE_SCORE_POST_LINK_ROOT"; fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) - .expect("system clock should be after epoch") + .expect("system clock should be after Unix epoch") .as_nanos(); std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) } @@ -112,3 +117,73 @@ fn process_killed_score_stage_is_recovered_before_next_publication() { let _ = std::fs::remove_dir_all(root); } + +#[test] +fn process_killed_after_publication_link_recovers_destination_without_stage_alias() { + if std::env::var_os(POST_LINK_CHILD_ENV).is_some() { + let root = PathBuf::from( + std::env::var_os(POST_LINK_ROOT_ENV) + .expect("post-link child score root should be supplied"), + ); + std::fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{POST_LINK_SCORE_ID}.stage")); + let destination = root.join(format!("{POST_LINK_SCORE_ID}.pdf")); + create_private_abandoned_stage(&stage); + std::fs::hard_link(&stage, &destination) + .expect("post-link fixture should expose the published destination alias"); + std::fs::write(root.join("post-link-ready"), b"ready") + .expect("post-link readiness marker should be written"); + + loop { + thread::sleep(Duration::from_secs(60)); + } + } + + let root = unique_test_dir("score-post-link-interruption-recovery"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut child = Command::new(test_binary) + .arg("--exact") + .arg("process_killed_after_publication_link_recovers_destination_without_stage_alias") + .arg("--nocapture") + .env(POST_LINK_CHILD_ENV, "1") + .env(POST_LINK_ROOT_ENV, &root) + .spawn() + .expect("post-link interruption child should start"); + + let marker = root.join("post-link-ready"); + let stage = root.join(format!(".score-{POST_LINK_SCORE_ID}.stage")); + let destination = root.join(format!("{POST_LINK_SCORE_ID}.pdf")); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(marker.exists(), "child should expose the post-link boundary"); + assert!(stage.exists(), "post-link stage alias should exist before termination"); + assert!( + destination.exists(), + "published destination should exist before process termination" + ); + + child.kill().expect("post-link child should be terminated"); + let status = child.wait().expect("post-link child should be reaped"); + assert!( + !status.success(), + "the child must end by process termination rather than normal cleanup" + ); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("post-link recovery should retain the published object as a recovery candidate"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), POST_LINK_SCORE_ID); + assert!( + !stage.exists(), + "post-link recovery should retire only the redundant staging alias" + ); + assert_eq!( + std::fs::read(&destination).expect("published recovery candidate should remain readable"), + b"%PDF-1.7\ninterrupted score bytes" + ); + + let _ = std::fs::remove_dir_all(root); +} From 48a7ccfab996330fa415e68afaabc34782d62ad4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:05:59 +0900 Subject: [PATCH 081/166] fix(score): recover verified post-link publication state --- apps/desktop/core/src/score_recovery.rs | 83 +++++++++++++++++++------ 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 9112d5706..1ff582178 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -158,6 +158,11 @@ fn published_score_id(name: &str) -> Option<&str> { is_valid_score_id(score_id).then_some(score_id) } +fn validated_pdf_content_sha256(path: &Path) -> Result { + let bytes = read_validated_score_pdf(path).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + sha256_hex_reader(Cursor::new(bytes)).map_err(|_| SCORE_RECOVERY_ERROR.to_string()) +} + fn recover_abandoned_score_stages( scores_root: &Path, lease: &ScoreWorkspaceLease, @@ -191,15 +196,28 @@ fn recover_abandoned_score_stages( } } - // A destination beside its staging alias means interruption may have - // happened after no-clobber publication. Score metadata persistence is - // not yet transactionally coupled to this storage layer, so deleting - // either pathname here could erase evidence or an attachment whose - // lifecycle authority is not established. Preserve both and fail - // closed; #1239 tracks that later lifecycle/recovery vertical. let destination = scores_root.join(format!("{score_id}.pdf")); match fs::symlink_metadata(&destination) { - Ok(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), + Ok(_) => { + // The publisher creates the destination as a hard link to the + // synchronized stage. After a process dies between link creation + // and stage retirement, both names therefore contain the same + // validated PDF bytes. Content equality is sufficient recovery + // evidence to retire only the temporary alias while preserving + // the buyer bytes as an unreferenced recovery candidate. A + // different destination remains ambiguous and is preserved. + resolve_existing_score_pdf(scores_root, score_id) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let stage_sha256 = validated_pdf_content_sha256(&stage)?; + let destination_sha256 = validated_pdf_content_sha256(&destination)?; + if stage_sha256 != destination_sha256 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + remove_score_pdf_attachment(&stage) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + removed += 1; + continue; + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), } @@ -247,9 +265,7 @@ fn receipt_for_score_id( } let path = resolve_existing_score_pdf(scores_root, score_id) .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; - let bytes = read_validated_score_pdf(&path).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; - let content_sha256 = sha256_hex_reader(Cursor::new(bytes)) - .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let content_sha256 = validated_pdf_content_sha256(&path)?; Ok(PublishedScorePdfReceipt { score_id: score_id.to_string(), content_sha256, @@ -350,18 +366,21 @@ pub fn remove_score_pdf_attachment_if_receipt_matches( /// The workspace lease spans recovery and the complete lower-level publication /// so another BandScope process using this contract cannot have a live writer /// mistaken for stale staging. A crash releases the OS lease automatically; -/// the next publication removes only reserved `.score-.stage` regular -/// files for which no `.pdf` destination exists, then proceeds through -/// the existing bounded/private/no-clobber Score Storage publisher. +/// the next operation removes a reserved `.score-.stage` regular file +/// when no destination exists. If a synchronized destination also exists and +/// both names contain the same validated bytes, recovery retires only the stage +/// alias and keeps the destination as a recovery candidate for Project +/// Persistence. Different stage/destination bytes remain ambiguous and fail +/// closed. /// /// Security Notes: malformed names are ignored because they are outside the /// owned staging namespace. Reserved symlink/reparse/non-regular entries, -/// lock acquisition failure, unreadable directory state, or a stage with a -/// destination already present fail closed. Errors contain neither absolute -/// paths nor PDF bytes. This recovery closes abandoned *staging* from writers -/// using this lease contract; it does not claim lifecycle authority for a -/// destination created before an interruption or compatibility with an older -/// concurrently running BandScope build that never acquired the lease. +/// lock acquisition failure, unreadable directory state, or a stage plus a +/// content-different destination fail closed. Errors contain neither absolute +/// paths nor PDF bytes. This recovery covers current-contract stage-only and +/// verified post-link interruption states; it does not claim compatibility +/// with an older concurrently running BandScope build that never acquired the +/// lease. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, @@ -486,6 +505,30 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn recovery_retires_equal_post_link_stage_and_keeps_destination() { + let root = unique_test_dir("stage-and-equal-destination"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + fs::write(&stage, b"%PDF-1.7\npublished").expect("stage fixture should be written"); + fs::hard_link(&stage, &destination) + .expect("post-link fixture should expose the destination alias"); + let lease = acquire_score_workspace_lease(&root).expect("recovery should acquire lease"); + + let removed = recover_abandoned_score_stages(&root, &lease) + .expect("equal stage and destination should recover non-destructively"); + + assert_eq!(removed, 1); + assert!(!stage.exists()); + assert_eq!( + fs::read(&destination).expect("published destination should remain"), + b"%PDF-1.7\npublished" + ); + drop(lease); + let _ = fs::remove_dir_all(root); + } + #[test] fn recovery_preserves_ambiguous_stage_plus_destination() { let root = unique_test_dir("stage-and-destination"); @@ -498,7 +541,7 @@ mod tests { let lease = acquire_score_workspace_lease(&root).expect("recovery should acquire lease"); let error = recover_abandoned_score_stages(&root, &lease) - .expect_err("ambiguous published state must fail closed"); + .expect_err("content-different published state must fail closed"); assert_eq!(error, SCORE_RECOVERY_ERROR); assert!(stage.exists()); From b24770de264bcb812efc70b0809ad9a9a735ac5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:06:32 +0900 Subject: [PATCH 082/166] docs(score): trace post-link crash recovery --- .../score-attachment-post-link-recovery.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/traceability/score-attachment-post-link-recovery.md diff --git a/docs/traceability/score-attachment-post-link-recovery.md b/docs/traceability/score-attachment-post-link-recovery.md new file mode 100644 index 000000000..8fd54878b --- /dev/null +++ b/docs/traceability/score-attachment-post-link-recovery.md @@ -0,0 +1,62 @@ +# Score attachment post-link interruption recovery + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment +Source RED: `1ce4bb86864ef845d39f7cb80614a2b686213499` +Causal repair: `48a7ccfab996330fa415e68afaabc34782d62ad4` + +## Problem + +Score Storage publishes a validated PDF by creating `.pdf` as a hard link to the synchronized `.score-.stage`, then retires the staging alias. A process can terminate after the destination link exists but before stage retirement. The next process previously treated any stage-plus-destination state as permanently ambiguous, so a valid published PDF could block inventory and every later Score Storage mutation even though both names still represented the same buyer bytes. + +This is a storage-recovery problem, not authority to infer durable project metadata. Project Persistence #970 remains the owner of whether an unreferenced published score should later be recovered, preserved, or discarded. + +## RED + +`score_pdf_interruption_recovery::process_killed_after_publication_link_recovers_destination_without_stage_alias` creates the exact persisted post-link shape in a child native process: + +1. write and `sync_all` a valid reserved stage; +2. create `.pdf` as a hard link to that stage; +3. expose a readiness marker and remain alive; +4. let the parent kill and reap the child; +5. start recovery through the public receipt inventory boundary. + +The pre-repair implementation unconditionally rejected any existing destination from `recover_abandoned_score_stages`, so the new recovery expectation is a deterministic source-level RED. No hosted RED is claimed unless that exact superseded head receives a terminal failing run. + +## Selected repair + +Recovery still holds the existing cross-process Score Storage workspace lease. For a reserved stage with a matching destination name it now: + +- validates the destination through the existing contained Score PDF resolver; +- reads both stage and destination through the existing bounded PDF validator; +- compares their shared-kernel SHA-256 content identities; +- retires only the stage alias when those validated bytes are equal; +- leaves `.pdf` intact so it becomes an ordinary published-object recovery candidate; +- preserves both names and fails closed when the bytes differ or either object cannot be safely validated. + +The equality check is content identity only. It is not a signature, authenticity proof, source-file provenance claim, or evidence that project attachment metadata was durable. A same-content but distinct file is also safe for this narrow cleanup because the only destructive action is removal of the temporary staging name while equivalent buyer bytes remain at the published destination. + +## Alternatives rejected + +Blindly deleting the stage whenever a destination exists is rejected because an unrelated destination could cause buyer bytes to be discarded. Blindly deleting the destination is rejected because it may be the only durable published copy after process death. Treating every stage-plus-destination pair as permanently ambiguous is non-destructive but leaves a recoverable crash state wedged indefinitely. Filesystem age, PID, or elapsed-time heuristics remain rejected because they do not prove content or lifecycle identity. + +## Security and durability boundary + +The workspace lease excludes another live writer using the current Score Storage contract. An older concurrently running build that never acquires this lease remains outside this guarantee and is still an explicit compatibility gap. The Unix lower-level delete path also retains its documented final basename race; this repair does not claim to close that separate issue. + +Recovery does not attach the surviving destination to a project and does not delete it as an orphan. The destination is surfaced only through Score Storage inventory/receipt APIs. Project Persistence must compare that owner truth with durable project attachment ids before any buyer-visible Recover/Preserve/Discard decision. + +## Test points + +- terminated stage-only writer is still recovered before the next publication; +- terminated post-link writer preserves the published destination and removes only the redundant stage alias; +- content-different stage plus destination remains preserved and fails closed; +- receipt inventory after post-link recovery returns the score id without exposing local paths, selected filenames, or PDF bytes. + +## References + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS) (FIPS PUB 180-4).* https://doi.org/10.6028/NIST.FIPS.180-4 + +The Open Group. (2024). *link, linkat — link one file to another file.* POSIX.1-2024, The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/link.html + +The Open Group. (2024). *unlink, unlinkat — remove a directory entry.* POSIX.1-2024, The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html From 943256e4106bf2aea7ce13cc05cee892cd6f55f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:06:49 +0900 Subject: [PATCH 083/166] ci(score): own post-link recovery traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 8066b9937..adf457572 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -20,6 +20,7 @@ on: - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" + - "docs/traceability/score-attachment-post-link-recovery.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -40,6 +41,7 @@ on: - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" + - "docs/traceability/score-attachment-post-link-recovery.md" - ".github/workflows/score-storage-native.yml" permissions: From 5ef0ab43c121f1e7e36d4cc5b80e96a00d2347da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:10:51 +0900 Subject: [PATCH 084/166] docs(score): currentize post-link crash recovery --- .../score-attachment-publication.md | 123 ++++++++++-------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/docs/traceability/score-attachment-publication.md b/docs/traceability/score-attachment-publication.md index 91edabacf..ef1264b66 100644 --- a/docs/traceability/score-attachment-publication.md +++ b/docs/traceability/score-attachment-publication.md @@ -5,14 +5,15 @@ Canonical owner: Score Storage / Score Attachment ## Problem and owner boundary -Score attachment bytes are buyer data. This bounded context owns score-PDF write-time confidentiality, bounded publication, final-object identity, abandoned-stage recovery, explicit removal and retention semantics. Native read-time allocation/content validation remains #865. App-owned project/workspace link, ACL and lifecycle authority remains Project Persistence #970. Score Storage consumes that workspace as a narrow filesystem boundary and does not copy Project Persistence policy. +Score attachment bytes are buyer data. This bounded context owns score-PDF write-time confidentiality, bounded publication, final-object identity, interrupted-publication recovery, object inventory/receipts, explicit removal and retention semantics. Native read-time allocation/content validation remains #865. App-owned project/workspace link, ACL and durable lifecycle authority remains Project Persistence #970. Score Storage consumes those boundaries narrowly and does not copy Project Persistence policy. -The original desktop command copied a selected PDF with `std::fs::copy`. Subsequent repairs established a descriptor-bounded 25 MiB copy, private staging, no-clobber publication, OS-object identity attestation and object-aware deletion. Two lifecycle problems are distinct: +The original desktop command copied a selected PDF with `std::fs::copy`. The current owner path uses descriptor-bounded publication, private staging, no-clobber hard-link publication, OS-object identity attestation, lease-scoped recovery and object-aware deletion. Three persistence states must remain distinct: 1. a successfully returned attachment must survive process teardown and fresh-process readback; -2. a process can die after selected PDF bytes have reached `.score-.stage`, leaving buyer bytes behind even though no attachment authority was returned. +2. a process can die with only synchronized `.score-.stage` bytes; +3. a process can die after `.pdf` has been hard-linked but before the temporary stage alias is retired. -The first is successful-publication durability/readability. The second is interrupted staging recovery. Neither is Project Persistence project-file recovery. +None of those filesystem states proves that Project Persistence durably accepted attachment metadata. ## Publication contract @@ -20,74 +21,85 @@ The first is successful-publication durability/readability. The second is interr - Reopen the admitted source once and bind copy to that descriptor. - Snapshot descriptor length; reject zero and `> 25 MiB`. -- Copy with a fixed 64 KiB buffer; early EOF and post-copy growth probe fail closed. +- Copy with a fixed 64 KiB buffer; early EOF and a post-copy growth probe fail closed. - Revalidate `%PDF-` on bytes actually copied. - Stage with `create_new`; Unix requests `0600` at first visibility. Windows inherits the app-owned parent DACL and native acceptance verifies inherited ACE evidence. - Keep the Windows write handle non-shareable until `sync_all` finishes. -- Publish by hard-link to `.pdf`; never overwrite an existing score id. +- Publish by hard link to `.pdf`; never overwrite an existing score id. - Attest destination identity to the synchronized stage object before and after retiring the stage alias: device/inode on Unix, volume serial + 128-bit `FILE_ID_INFO` on Windows. -- Return the descriptor-bound byte count, not a stale path metadata value. +- Return the descriptor-bound byte count, not a stale pathname metadata value. -Windows stage cleanup reopens the exact non-reparse stage with DELETE authority, verifies file identity and applies `FileDispositionInfo` to that handle. Unix cleanup still has a documented final identity-check-to-path-unlink race. +Windows stage cleanup reopens the exact non-reparse stage with DELETE authority, verifies file identity and applies `FileDispositionInfo` to that handle. Unix cleanup/delete pins the parent directory and rechecks device/inode before `unlinkat`; its final basename race remains an explicit residual risk. -## Process-abandoned staging recovery +## Process-abandoned staging and post-link recovery -The crate-root `publish_score_pdf_attachment` now routes through `score_recovery` before calling the lower-level publisher. +The crate-root `publish_score_pdf_attachment` routes through `score_recovery` before calling the lower-level publisher. Inventory and receipt-bound deletion use the same recovery owner. -A cross-process Score Storage workspace lease spans both recovery and the complete publication: +A cross-process Score Storage workspace lease spans recovery and the complete owner mutation: - Unix opens persistent `.score-storage.lock` with `O_NOFOLLOW`, mode `0600`, then takes non-blocking exclusive `flock`. - Windows opens the persistent lock file with no sharing and `FILE_FLAG_OPEN_REPARSE_POINT`; a reparse lock object is rejected. -- The operating system releases the held lease when the process exits, including abnormal termination. The lock file itself contains no buyer payload. -- Recovery only examines the reserved exact namespace `.score-.stage`. Malformed names are outside Score Storage ownership and are ignored rather than broadened into a glob-delete contract. +- The OS releases the held lease when the process exits, including abnormal termination. The lock file contains no buyer payload. +- Recovery examines only exact `.score-.stage` names. Malformed names remain outside Score Storage ownership. - A reserved stage must be a regular, non-reparse object. Suspicious reserved objects fail closed. -- A stage-only orphan with no matching `.pdf` is removed through the existing Score Storage object-deletion boundary. -- If both `.score-.stage` and `.pdf` exist, recovery fails closed and preserves both. Interruption may have happened after hard-link publication but before higher-level attachment metadata persistence; Score Storage does not infer buyer lifecycle intent from those two names. +- A stage-only orphan with no matching destination is removed through the Score Storage object-deletion boundary. +- If a destination exists, recovery validates it through the existing contained score resolver, validates both stage and destination as bounded PDFs, and compares their shared-kernel SHA-256 content identities. Equal validated bytes permit removal of **only the temporary stage alias**; the destination remains a published recovery candidate. Different bytes or indeterminate validation preserve both and fail closed. -This lease prevents another process that uses the same current Score Storage contract from being mistaken for a stale writer. It deliberately does **not** claim compatibility with an older concurrently running BandScope build that never acquires the lease. Broader ancestor-link/workspace authority still belongs to #970 and requires fresh reconciliation after #970 integrates. +The equal-content rule is intentionally narrow. SHA-256 here is an equality/content-identity receipt, not a signature, authenticity proof, original-file provenance or evidence that project metadata was durable. Even if the two names refer to distinct same-content objects, retiring the reserved stage is non-destructive because equivalent buyer bytes remain at the published destination. + +`score_pdf_interruption_recovery` now covers both stage-only process death and the post-link process-death state. The post-link source RED is `1ce4bb86864ef845d39f7cb80614a2b686213499`; causal repair is `48a7ccfab996330fa415e68afaabc34782d62ad4`. `docs/traceability/score-attachment-post-link-recovery.md` contains the focused decision record and references. + +This lease excludes another live writer using the current contract. Compatibility with an older concurrently running BandScope build that never acquires the lease is not claimed and remains open. + +## Published-object inventory and mutation freshness + +Restart discovery has two contracts: + +- `inventory_published_score_pdf_ids` is discovery-only compatibility surface; +- `inventory_published_score_pdf_receipts` returns path-free `{score_id, content_sha256}` receipts under the Score Storage lease after recovery and bounded object validation. + +The receipt closes same-id ABA for later destructive recovery. `remove_score_pdf_attachment_if_receipt_matches` reacquires the lease, recovers abandoned staging, computes a fresh current receipt and deletes only when it still equals the caller's receipt. Missing current object or digest mismatch is a safe non-removal; unsafe or indeterminate storage remains an error. + +The same-id ABA source RED is `4d8d6c10761235c64ce5c5f70e4a0567ad5d4383`, refined by `c1a4c78da0f4d76c67967dc2d05df5019599d511`. The exact predecessor `43fd4666eb92a9376d5253610753de7240596a54` completed owner-native run `35507283561` GREEN on macOS 15 (`106069129664`) and Windows Server 2025 (`106069129795`). Those results are predecessor evidence only and are not transferred to a later source head. ## Retention/delete contract `resolve_score_pdf_for_removal` distinguishes genuine absence from unsafe or indeterminate storage: - validate score id before local name construction; -- require the supplied scores workspace to exist as a directory before a child `NotFound` can mean absence; +- require the supplied score workspace to exist as a directory before child `NotFound` can mean absence; - return `Ok(None)` only for an observed missing `.pdf` child under that existing workspace; - once an entry exists, preserve symlink/non-regular/canonicalization/containment/permission/concurrent-disappearance failures as errors. -`remove_score_pdf_attachment` then owns final object deletion. Windows marks the exact opened object for deletion with `FileDispositionInfo`. Unix pins the parent directory, opens the basename with `openat(..., O_NOFOLLOW)`, rechecks device/inode and then calls `unlinkat`; the final basename race remains explicit. +`remove_score_pdf_attachment` owns final object deletion. Windows marks the exact opened object for deletion with `FileDispositionInfo`. Unix pins the parent directory, opens the basename with `openat(..., O_NOFOLLOW)`, rechecks device/inode and then calls `unlinkat`; the final identity-check-to-`unlinkat` basename race remains explicit. Recovery `Discard` must use the receipt-bound delete rather than id-only deletion. ## UI/application lifecycle ordering -The Score view is the application-service boundary that coordinates buyer-visible attachment metadata with Score Storage bytes; it does not move either bounded context's lower-level authority into React. +ScoreView coordinates buyer-visible attachment metadata with Score Storage bytes without moving either lower-level bounded-context authority into React. -Attachment is necessarily a two-step operation: publish and verify the PDF first, then ask the owning project mutation callback to accept the new attachment metadata. The callback may be asynchronous. A return value of `false` means the owning Project Persistence path did not durably accept that metadata. In that case ScoreView does **not** open or otherwise present the newly published PDF as an accepted attachment, and it deliberately does not delete those bytes: the PDF is a recovery candidate until #970 supplies the durable attachment lifecycle/reconciliation contract. Legacy synchronous callbacks returning `void` remain accepted for the pre-#970 stack. +Attachment is a two-step operation: publish and verify the PDF first, then ask Project Persistence to accept attachment metadata. An asynchronous callback result of `false` means metadata was not durably accepted. ScoreView then does not present the PDF as an accepted attachment and does not delete the bytes; the published object remains a recovery candidate. Legacy synchronous `void` remains pre-#970 compatibility, not durability evidence. -Detach uses the opposite safety ordering because deletion is destructive. ScoreView first asks the project owner to accept metadata without the attachment. Only after that update is accepted does it call the Score Storage deletion boundary. A rejected metadata update therefore leaves the still-referenced PDF bytes intact. If metadata removal succeeds but byte deletion later fails, the project no longer holds a broken reference to missing bytes; the remaining file is an unreferenced cleanup/recovery candidate and the storage error is surfaced. This is intentionally preferable to deleting buyer bytes first and then discovering that durable metadata still references them. +Detach uses the opposite order because deletion is destructive. ScoreView first asks the project owner to accept metadata without the attachment. Only after acceptance may Score Storage delete bytes. If metadata removal succeeds but deletion fails, the remaining PDF is an unreferenced cleanup/recovery candidate rather than a broken durable project reference. -`ScoreView.persistenceOutcome.test.tsx` covers three application-level invariants: rejected attachment metadata does not trigger PDF read/open acceptance; rejected detach metadata never calls destructive removal; accepted detach invokes metadata acceptance before Score Storage deletion. This is ordering evidence only. It does not claim an atomic filesystem-plus-project transaction, restart reconciliation, multi-window serialization, or packaged crash coverage. +`ScoreView.persistenceOutcome.test.tsx` covers rejected attachment metadata, rejected detach metadata and metadata-before-delete ordering. This is ordering evidence only; it is not an atomic filesystem/project transaction or packaged crash evidence. ## Successful-publication restart/readback -`score_pdf_restart_readback` publishes through the production crate-root boundary, starts a fresh native test process, resolves the stored score through `resolve_existing_score_pdf`, reads it through `read_validated_score_pdf` and requires exact bytes. The success path also requires zero `.score-*.stage` aliases after publication. - -This proves a successfully returned attachment survives one process lifetime and is readable through the normal bounded native path. It is separate from interrupted-writer recovery. +`score_pdf_restart_readback` publishes through the production crate-root boundary, starts a fresh native process, resolves the stored object through `resolve_existing_score_pdf`, reads it through `read_validated_score_pdf` and requires exact bytes. A successful ordinary publication also requires no stage alias to remain. -## Interruption RED and repair +This proves successfully returned bytes survive one process lifetime and are readable through the normal bounded native path. It remains distinct from interrupted-writer recovery and Project Persistence reconciliation. -Commit `2c55e34c7262d087783502050cd9f084a1f7255c` added `score_pdf_interruption_recovery`. A child native test process writes and synchronizes selected PDF fixture bytes into the exact reserved `.score-.stage` shape, exposes a readiness marker and remains alive. The parent confirms the stage exists, terminates and reaps that child, then attempts a different score publication through the production crate-root API and requires all score staging aliases to be gone before attachment authority returns. +## Interruption lineage -`45ae83d84f7416f9d0567fa1a47f65f0854a086a` put that regression into the macOS/Windows owner workflow. Descendant pushes superseded the early workflow run before a terminal RED verdict, so no hosted RED is claimed for that source. The pre-repair behavior is nevertheless a deterministic source RED: publishing a different UUID had no code path that removed the abandoned earlier stage, so the final zero-stage assertion could not hold. +Stage-only source RED `2c55e34c7262d087783502050cd9f084a1f7255c` created synchronized reserved staging in a child process, killed/reaped that process, then required a later production publication to recover the orphan. `ad2c4633fba5a9a981859407a035a2b9c367f9ec` added the OS-released workspace lease/recovery and `a1f21f1310ab6af96109def3575220e0017e19a2` routed the public publisher through it. -`ad2c4633fba5a9a981859407a035a2b9c367f9ec` added the OS-released workspace lease and reserved-stage recovery. `a1f21f1310ab6af96109def3575220e0017e19a2` routed the public crate-root publisher through recovery. `f4962d21b77148ff7e90ac4349979d666ef32d75` tightened the owner workflow so both lower-level Score Storage and recovery unit suites plus publication, retention, interruption, restart and wiring regressions execute. +Post-link source RED `1ce4bb86864ef845d39f7cb80614a2b686213499` creates the persisted hard-link state in a child, kills/reaps the child and requires fresh receipt inventory to preserve the destination while retiring only the redundant stage. Repair `48a7ccfab996330fa415e68afaabc34782d62ad4` implements validated equal-content recovery. No hosted RED is claimed for that superseded test-only head unless an exact terminal failing run exists. -At `f4962d21b77148ff7e90ac4349979d666ef32d75`, Windows Server 2025 job `105959193774` in run `35466321613` passed checkout, Rust 1.97.1, both owned unit suites and all publication/retention/interruption/restart/wiring regressions. The document update is a later source identity, so exact-current-head native evidence must be reacquired and recorded on PR #1241 rather than transferring this predecessor verdict. - -The process-kill acceptance is intentionally scoped: it verifies recovery of a synchronized **stage-only** artifact left by a terminated process, followed by a production publication. It does not claim that the test kills the lower-level publisher at every internal instruction point. Packaged-app process-kill, disk-full, permission failure, explicit cancellation, power loss and stage-plus-destination lifecycle recovery remain separate acceptance. +The process-kill acceptance is still scoped. It exercises native persisted shapes and process termination, but not every internal instruction point of a packaged application. Explicit cancellation, disk-full, permission failure, power loss and packaged-executable fault injection remain separate acceptance work. ## Earlier hosted evidence retained -- Windows share-mode repair: run `35451457938` isolated the hard-link/unlink failure while the exclusive write handle was live. +- Windows share-mode repair: run `35451457938` isolated hard-link/unlink failure while the exclusive write handle was live. - Windows foreign-stage replacement RED `a7373ca153e8740923ad13098953a61a7c263cc0`: run `35452152802`, Windows `105921067591`, failed; `49976461c7158869d1f10a6a43b46e0ee522352f` moved identity to `FILE_ID_INFO`. - Windows identity-check-to-unlink RED `89f136e312ded829b89f2beafda68ca1c373044c`: run `35454589087` failed Windows `105927525168`; `d36969bf4e3ed2c470c00db0bb07b1de0ec2fa70` made cleanup handle-bound and run `35454808019` passed Windows/macOS. - Tauri direct-delete wiring RED `06cc24a1b0bf89d1525cfb0b06921e9229e7b5dd`: run `35453934315` failed Windows/macOS; `09610fb26127408f818a754a42b75a083e0e5045` routed removal through Score Storage and run `35454259017` passed. @@ -95,58 +107,61 @@ The process-kill acceptance is intentionally scoped: it verifies recovery of a s - Final destination identity RED `3274560cff4b8ecec5856d6f358e1a5fcea1c212`: run `35456813143` failed macOS `105933454955` and Windows `105933455061`; `91c240385acdc7ae5dea4a2ebd7c32db0f53953f` added OS-object identity and run `35456879153` passed. - Missing-workspace retention RED `22749b83d9c7e6f7e3c0b5986af5802427e98d2a`: run `35460119001` failed macOS `105942349093` and Windows `105942349170`; `6444f21a158a877ae402fd665469d908f8afa3a2` repaired classification and run `35460197487` passed. - Successful-publication fresh-process readback: `297327de172bbe30cdf226518a5135605db983ef` plus workflow wiring `0e4070ef6d92d55a91463e8bc4c1fa6b71f86003`; run `35463057196` passed macOS `105950252549` and Windows `105950252698`. +- Same-id ABA/content-receipt predecessor: exact `43fd4666eb92a9376d5253610753de7240596a54`, run `35507283561`, macOS `106069129664` and Windows `106069129795` SUCCESS. ## Alternatives rejected -Blind `.score-*.stage` glob deletion is rejected because a live concurrent writer can own those bytes. File age/mtime heuristics are rejected because elapsed time is not ownership or liveness proof. PID-only or mutable sidecar ownership is rejected because stale metadata and process-ID reuse do not create a robust lease. The selected OS-held lease dies with the process and spans recovery plus publication. +Blind `.score-*.stage` glob deletion is rejected because a live writer can own those bytes. File age/mtime and PID-only heuristics are rejected because elapsed time or process identifiers do not create ownership proof. Blind stage deletion when a destination exists is rejected because unrelated/different bytes may occupy that destination. Blind destination deletion is rejected because it may be the only durable published buyer copy. -Deleting a matching destination while recovering a stage is rejected because filesystem publication is not yet transactionally coupled to the buyer-visible attachment metadata lifecycle. A stage-plus-destination state is ambiguous and is preserved for the later lifecycle/recovery vertical. +Permanently treating every stage-plus-destination state as ambiguous is non-destructive but leaves a valid post-link process crash wedged indefinitely. The selected equal-validated-content rule removes only the temporary alias and leaves the published copy for higher-level lifecycle reconciliation. -Deleting the stored PDF before the owning project accepts attachment removal is rejected because a failed metadata commit can leave a durable project pointing at bytes that the same interaction already destroyed. Detach therefore commits metadata first and treats a later delete failure as an orphan-cleanup problem instead of a broken-reference problem. +Deleting a stored PDF before Project Persistence accepts attachment removal is rejected because a failed metadata commit can leave a durable project pointing at bytes already destroyed. Detach therefore commits metadata first and treats later delete failure as an orphan recovery problem. -`std::fs::copy`, create-then-`chmod`, process-wide `umask` mutation, overwrite publication, length-only destination checks, pathname hashing as primary identity and a second pathname `stat` before deletion remain rejected for the reasons encoded in the corresponding REDs: each leaves visibility, mutation, clobber or pathname/object identity gaps. +`std::fs::copy`, create-then-`chmod`, process-wide `umask` mutation, overwrite publication, length-only destination checks, pathname hashing as primary object identity and id-only recovery deletion remain rejected. ## Security Notes -**Untrusted input.** Selected PDF bytes/path and pre-existing destination, stage, lock and retention names are untrusted. No selected PDF bytes or absolute buyer path are emitted in ordinary errors. +**Untrusted input.** Selected PDF bytes/path and every pre-existing destination, stage, lock and retention entry are untrusted. Ordinary errors emit neither selected PDF bytes nor absolute buyer paths. -**Trust boundaries.** OS-selected source → source admission → Score Storage workspace lease → reserved-stage recovery → descriptor-bounded private stage → no-clobber/object-identity-attested attachment. Removal remains project-metadata acceptance → validated score id → existing workspace precondition → exact child resolution → OS-object deletion. #970 still owns broader app-owned workspace ancestry/link and durable project-metadata authority. +**Trust boundaries.** OS-selected source → source admission → Score Storage lease/recovery → descriptor-bounded private stage → no-clobber/object-identity-attested publication → receipt-bearing restart observation/mutation. Project Persistence #970 owns durable project references and buyer lifecycle intent. -**Safe failure.** Oversize, growth/truncation, wrong magic, duplicate destination, copy/sync failure, suspicious reserved stage, reparse/symlink/non-regular object, identity mismatch, lease acquisition failure, missing/indeterminate workspace, unsafe resolution and stage-plus-destination ambiguity fail closed. Rejected project-metadata acceptance does not trigger destructive score deletion. Recovery does not turn ambiguous lifecycle state into deletion. +**Safe failure.** Oversize, growth/truncation, wrong magic, duplicate destination, copy/sync failure, suspicious reserved stage, reparse/symlink/non-regular object, identity mismatch, lease acquisition failure, missing/indeterminate workspace, unsafe resolution, content-different stage/destination and stale receipt mismatch fail closed. Recovery never converts those cases into destructive guessing. -**Privacy.** Unix stage and lock creation request `0600`. Windows deliberately consumes the parent DACL contract. The lock file contains no PDF payload. The native interruption fixture uses generated test PDF bytes only. +**Privacy.** Unix stage and lock creation request `0600`. Windows deliberately consumes the parent DACL contract. The lock file has no PDF payload. Native interruption fixtures use generated unit/integration-test PDF bytes only; they are not scientific MIR acceptance data. -**Test points.** Native tests cover valid/no-clobber publication, same-length destination replacement, Unix permissive-umask privacy, Windows parent-DACL inheritance, source growth/truncation, Tauri wiring, Windows handle-bound cleanup/delete replacement cases, Unix pre-`unlinkat` replacement, removal classification, successful fresh-process readback, reserved-stage namespace parsing, and process-terminated stage-only recovery before the next production publication. Frontend ordering tests cover rejected attach metadata, rejected detach metadata and metadata-before-delete ordering. +**Test points.** Native tests cover bounded/no-clobber publication, same-length destination replacement, Unix permissive-umask privacy, Windows parent-DACL inheritance, source growth/truncation, Tauri wiring, handle/object-bound Windows cleanup/delete cases, Unix pre-`unlinkat` replacement, removal classification, successful fresh-process readback, stage-only process termination, post-link process termination, reserved namespace parsing, restart inventory and same-id ABA receipts. Frontend ordering tests cover metadata acceptance before destructive deletion. ## Remaining risk / claim boundary -Stage-only process-kill recovery is now implemented under the current Score Storage lease contract. UI/application ordering now prevents a rejected metadata detach from deleting referenced bytes, and it refuses to present an asynchronously rejected attachment as accepted. Still open are interruption after a destination has been hard-linked, restart reconciliation of a published attachment whose metadata commit never became durable, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback semantics, packaged-app fault evidence, and compatibility with an older concurrently running build that does not acquire the lease. +Current-contract stage-only and verified equal-content post-link process-kill states are recoverable. A published object whose metadata commit never became durable remains only a recovery **candidate** until Project Persistence compares durable attachment ids with fresh Score Storage receipts and obtains explicit buyer intent. `missing_referenced_score_ids` remains a broken-reference condition, not deletion authority. -The Windows parent-DACL acceptance must be rerun after #970 workspace authority integrates. Parent-directory durability and project lifecycle remain #970 concerns; Score Storage must reconcile without copying that implementation. The `void` callback compatibility path remains only for the pre-#970 synchronous owner and is not evidence of durable metadata acceptance. +Still open: old concurrent builds that do not acquire the lease, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback semantics, packaged fault evidence, the Unix final basename race, #865 protected integration, #970 released consumer reconciliation, independent approval and current-head repository/security settlement. -A successful final publication attests object identity at the covered boundary but does not make the pathname immutable afterward. Windows cleanup/delete is object-bound for the tested contract. Unix cleanup/delete retains documented final basename races. A removal `Ok(None)` remains an observed absence, not an atomic reservation. +Windows parent-DACL acceptance must be rerun after #970 workspace authority integrates. Parent-directory durability and project lifecycle remain #970 concerns. The `void` callback compatibility path remains pre-#970 compatibility only. The implementation remains stacked on #865 because write-time publication and read-time bounded validation share the score-native crate surface. #865 must integrate first. Any descendant restack requires fresh exact-head evidence; predecessor CI is not transferable. ## References -Microsoft. (2024, February 22). *FILE_ID_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_id_info +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS) (FIPS PUB 180-4).* https://doi.org/10.6028/NIST.FIPS.180-4 + +Microsoft. (2024, February 22). *FILE_ID_INFO structure (winbase.h).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_id_info -Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getfileinformationbyhandleex +Microsoft. (2024, February 22). *GetFileInformationByHandleEx function (winbase.h).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getfileinformationbyhandleex -Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info +Microsoft. (2024, February 22). *FILE_DISPOSITION_INFO structure (winbase.h).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info -Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle +Microsoft. (2024). *SetFileInformationByHandle function (fileapi.h).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle -Microsoft. (n.d.). *CreateFileA function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea +Microsoft. (n.d.). *ACE inheritance rules.* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules -Microsoft. (n.d.). *ACE inheritance rules*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules +Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h).* Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow -Microsoft. (n.d.). *GetNamedSecurityInfoW function (aclapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfow +The Open Group. (2024). *link, linkat — link one file to another file.* POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/link.html -The Open Group. (2024). *unlink, unlinkat — remove a directory entry*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html +The Open Group. (2024). *unlink, unlinkat — remove a directory entry.* POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html -The Open Group. (2024). *open, openat — open file relative to a directory file descriptor*. POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html +The Open Group. (2024). *open, openat — open file relative to a directory file descriptor.* POSIX.1-2024 / The Open Group Base Specifications Issue 8. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html The Rust Project Developers. (2026). *OpenOptions — std::fs*. Rust documentation. https://doc.rust-lang.org/std/fs/struct.OpenOptions.html From f8dca4ef28bb0b51947439c8f730532d94a9443b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 21:11:56 +0900 Subject: [PATCH 085/166] docs(score): currentize recovery inventory semantics --- .../score-attachment-recovery-inventory.md | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/docs/traceability/score-attachment-recovery-inventory.md b/docs/traceability/score-attachment-recovery-inventory.md index ad7a571c6..413e22f41 100644 --- a/docs/traceability/score-attachment-recovery-inventory.md +++ b/docs/traceability/score-attachment-recovery-inventory.md @@ -6,61 +6,75 @@ Related owner: Project Persistence #970 ## Problem -A score PDF can be durably published before its attachment metadata becomes durable in the project document. After a process restart, Project Persistence can only reconcile that window if Score Storage can report which score objects actually exist without guessing whether those objects are accepted project attachments. +A score PDF can be durably published before its attachment metadata becomes durable in the project document. After restart, Project Persistence can reconcile that window only if Score Storage can report which score objects actually exist without guessing whether those objects are accepted project attachments. -The pre-repair Score Storage API could publish, read and remove a score by known id, and could recover a stage-only artifact before the next publication. It had no restart-safe inventory contract. Project Persistence therefore had no narrow owner API from which to derive `published object - durable project reference` candidates. +The original Score Storage API could publish, read and remove a score by known id but had no restart-safe object inventory. The first inventory repair added deterministic id discovery; later repairs added content-bound receipts for mutation freshness and recoverable post-link interruption state. ## Owner boundary -Score Storage owns filesystem object truth only. `inventory_published_score_pdf_ids` returns validated score ids for safely published `.pdf` objects. It does **not** decide whether an id is referenced by durable project metadata, whether an unreferenced object came from interrupted attach versus failed cleanup after detach, or whether the buyer wants to recover or discard it. +Score Storage owns filesystem object truth only. It does **not** decide whether a published id is referenced by durable project metadata, whether an unreferenced object came from interrupted attach versus failed cleanup after detach, or whether the buyer wants Recover / Preserve / Discard. -Project Persistence #970 remains responsible for durable project references and any later reconciliation decision. A future consumer must compare its own durable attachment references against this object inventory through an explicit application/ACL boundary. Cross-service SQL, filesystem-path inference and source copying are not part of this contract. +Project Persistence #970 owns durable project references and buyer lifecycle intent. A consumer must compare its durable attachment references with Score Storage inventory through an explicit application/ACL boundary. Cross-service SQL, filesystem-path inference and source copying are outside this contract. -## RED and causal repair +## RED and causal repair lineage -`7979ea23c2b3733c4175f7c969aa196fc6b672c5` added `score_pdf_recovery_inventory.rs`. The regression requires a fresh native process to rediscover two production-published score ids in deterministic order, requires stage-only abandonment to be recovered before inventory, and requires stage-plus-destination ambiguity to remain preserved and fail closed. `25fcd0a31e948c7caef68bcc17ce7e3813c21675` put that regression in the owned macOS/Windows Score Storage workflow. At that source identity the public inventory symbol did not exist, so the new target is a deterministic source RED; no hosted RED is claimed unless a terminal run for that exact test-only lineage is available. +Initial inventory RED `7979ea23c2b3733c4175f7c969aa196fc6b672c5` added `score_pdf_recovery_inventory.rs`. It required fresh-process rediscovery of production-published score ids in deterministic order, stage-only abandonment recovery before inventory, and fail-closed handling for then-unresolved stage-plus-destination state. `a9acc39b2cbf06d3ed09d879ceea1b833ee10614` added the inventory implementation; `1765fa075fa10106228566d75f5727ead8ceb405` corrected its resolver dependency to the canonical crate-root containment boundary. -`a9acc39b2cbf06d3ed09d879ceea1b833ee10614` added the inventory implementation. Self-review then found that its containment resolver import named the wrong sibling owner. `1765fa075fa10106228566d75f5727ead8ceb405` repaired that dependency to the canonical crate-root `resolve_existing_score_pdf` boundary rather than duplicating read-time path validation. +The id-only inventory was later found insufficient as destructive mutation authority because the same valid score id can be removed and republished with different bytes. Source RED `4d8d6c10761235c64ce5c5f70e4a0567ad5d4383`, refined by `c1a4c78da0f4d76c67967dc2d05df5019599d511`, established the same-id ABA case. The repair added path-free `{score_id, content_sha256}` receipts and lease-scoped fresh receipt validation before deletion. See `score-attachment-recovery-object-receipt.md`. -The public crate-root API is re-exported by `f7b31910fe203b42c1c595044a7e884946fe6b1d` and retained at the current descendant head. +Post-link source RED `1ce4bb86864ef845d39f7cb80614a2b686213499` then established a process-death state where synchronized `.score-.stage` and hard-linked `.pdf` both survive. Causal repair `48a7ccfab996330fa415e68afaabc34782d62ad4` makes equal validated stage/destination bytes recoverable by retiring only the temporary stage alias while preserving the destination as a published recovery candidate. Content-different or indeterminate pairs still preserve both and fail closed. See `score-attachment-post-link-recovery.md`. ## Inventory contract -Before returning any object ids, the owner: +Before returning published-object truth, the owner: -- validates and acquires the existing cross-process Score Storage workspace lease; -- runs the existing abandoned-stage recovery while that lease is held; -- fails closed if stage-plus-destination state is ambiguous; -- enumerates only exact `.pdf` names; -- resolves every matching owned name through the existing bounded containment/symlink guard; +- validates and acquires the cross-process Score Storage workspace lease; +- runs abandoned-stage recovery while that lease is held; +- removes a stage-only orphan through the existing object-deletion boundary; +- for stage plus destination, validates both bounded PDF byte streams and retires only the stage alias when their shared-kernel SHA-256 identities are equal; +- preserves both and fails closed when stage/destination bytes differ or validation is indeterminate; +- enumerates only exact `.pdf` names; +- resolves every matching owned name through the existing native containment/symlink guard; - ignores unrelated filenames instead of broadening Score Storage ownership; -- sorts returned ids deterministically; -- returns ids only, never local paths, selected-source filenames or PDF payload bytes. +- sorts results deterministically; +- returns no local paths, selected-source filenames or PDF payload bytes. -The lease makes the inventory a stable observation with respect to another current-contract writer. It does not make older builds that never take the lease compatible. +`inventory_published_score_pdf_ids` remains discovery-only compatibility surface. `inventory_published_score_pdf_receipts` is the mutation-freshness observation surface: each receipt binds the validated logical id to the SHA-256 of the current bounded PDF bytes while the lease is held. The digest is equality/content identity only, not authenticity, provenance or durable project acceptance. + +The lease makes inventory stable with respect to another writer using the current contract. It does not make an older build that never takes the lease compatible. + +## Mutation freshness + +A recovery decision must not delete by score id alone. `remove_score_pdf_attachment_if_receipt_matches` reacquires the same Score Storage lease, performs recovery, computes the current receipt and removes only when the supplied receipt still matches. A missing object or digest mismatch is a safe non-removal; suspicious or indeterminate storage remains an error. + +This closes `A removed -> same id republished as B -> stale decision for A` at the storage owner. It does not authorize the higher-level decision to discard. Project Persistence/application orchestration must separately prove current project identity, fresh recovery classification and explicit buyer intent. ## Alternatives rejected -Scanning the scores directory from React is rejected because it exports filesystem authority across the native boundary and duplicates Score Storage validation. Treating every `*.pdf` as owned is rejected because unrelated or attacker-planted names would broaden the deletion/recovery namespace. Returning absolute paths is rejected because consumers need object identity, not filesystem authority. Automatically attaching or deleting unreferenced ids is rejected because Score Storage cannot infer Project Persistence lifecycle intent. +Scanning the scores directory from React is rejected because it exports filesystem authority across the native boundary and duplicates Score Storage validation. Treating every `*.pdf` as owned is rejected because unrelated or attacker-planted names broaden the recovery/deletion namespace. Returning absolute paths is rejected because consumers need opaque object identity, not filesystem authority. + +Automatic attachment or deletion of unreferenced ids is rejected because byte existence cannot reveal durable project intent. Id-only destructive authority is rejected because same-id ABA can apply stale intent to replacement bytes. Blind deletion of either side of stage-plus-destination is rejected because one side may be the only durable buyer copy; permanent rejection of every equal-content post-link state is also rejected because a valid current-contract crash would wedge the workspace indefinitely. -A mutable sidecar lifecycle ledger is not introduced in this slice. It would create a second persistence protocol whose crash semantics must themselves be reconciled with the project document. The narrower object inventory is sufficient to let the canonical owners meet at an ACL later without pretending that byte existence equals project acceptance. +A mutable sidecar lifecycle ledger is not introduced in this slice. It would create a second persistence protocol whose crash semantics would themselves need reconciliation with the project document. ## Security Notes -**Untrusted input.** Every directory entry under the scores workspace is untrusted. Only an exact valid score-id filename enters the owned inventory. +**Untrusted input.** Every directory entry under the score workspace is untrusted. Only exact reserved stage names and exact valid score-id PDF names enter the owned recovery/inventory namespace. -**Trust boundary.** App-owned score workspace → OS-held Score Storage lease → abandoned-stage recovery → exact owned-name parsing → existing native containment resolver → score-id-only inventory. +**Trust boundary.** App-owned score workspace → OS-held Score Storage lease → abandoned/publication recovery → exact owned-name parsing → existing native containment resolver and bounded PDF validator → id inventory or content receipt. -**Safe failure.** Lease contention, unreadable directory state, suspicious matching objects, unsafe resolution and stage-plus-destination ambiguity fail closed. The inventory never converts ambiguity into delete or project metadata mutation. +**Safe failure.** Lease contention, unreadable directory state, suspicious matching objects, unsafe resolution, content-different stage/destination state and stale receipt mismatch do not fall back to destructive guessing. Equal-content post-link recovery removes only the temporary stage alias and preserves published bytes. -**Privacy.** The API exposes only BandScope-generated score ids. It does not return local paths, original selected filenames or PDF bytes. +**Privacy.** The APIs expose BandScope-generated score ids and, for receipts, lowercase SHA-256 content identity. They do not return local paths, original selected filenames or PDF bytes. ## Test points -`score_pdf_recovery_inventory` covers fresh-process rediscovery of production-published objects, deterministic ordering, ignoring an unrelated `notes.pdf`, stage-only cleanup before listing, and preservation/failure for stage-plus-destination ambiguity. Existing Score Storage unit and publication/retention/interruption/restart/wiring regressions remain in the same owner workflow. +`score_pdf_recovery_inventory` covers fresh-process rediscovery of production-published objects, deterministic ordering, unrelated-file exclusion and stage recovery before listing. `score_pdf_interruption_recovery` covers stage-only process termination and post-link process termination. `score_pdf_recovery_object_receipt` covers same-id remove/republish ABA and fresh receipt-bound deletion. Existing publication/retention/restart/wiring regressions remain in the same owner workflow. ## Claim boundary and next step -This slice creates the Score Storage half of restart reconciliation. It does **not** identify which returned ids are referenced by the durable project document, recover attachment metadata, infer original display filenames, delete unreferenced objects, or provide buyer-visible recovery UX. Those decisions require #970 Project Persistence integration and a narrow consumer contract that compares durable project references against this object inventory. +Score Storage now provides restart object discovery, content-bound mutation freshness and non-destructive recovery for current-contract stage-only and equal-content post-link process death. It still does **not** identify which objects are referenced by the durable project document, infer original display filenames, decide Recover / Preserve / Discard, or provide buyer-visible recovery UX. + +Until #865 and #1241 integrate and #970 consumes a protected/released Score Storage contract, `PDF durable -> metadata not durable` remains an open commercial gap. The consumer flow must combine durable project attachment ids, active project identity and fresh Score Storage receipts, then re-read both owner truths immediately before a mutation. `missing_referenced_score_ids` remains a broken-reference condition rather than cleanup authority. -Until that consumer exists, `PDF durable -> metadata not durable` remains an open commercial gap. The correct next step is to integrate the protected Project Persistence owner, pass durable attachment ids rather than filesystem paths across the ACL, and expose ambiguous candidates to a buyer-visible recovery decision without automatic attach/delete. \ No newline at end of file +Still open at the Score Storage owner: old unleased-build compatibility, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback, the remaining Unix final basename race, packaged fault evidence and release/signing settlement. From 49e886f4d43cfacab3bc891b1f7a8ef02089ce2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:03:28 +0900 Subject: [PATCH 086/166] test(score): require successful-publication durability boundary --- .../core/tests/score_pdf_success_durability.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_success_durability.rs diff --git a/apps/desktop/core/tests/score_pdf_success_durability.rs b/apps/desktop/core/tests/score_pdf_success_durability.rs new file mode 100644 index 000000000..de3b35e30 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_success_durability.rs @@ -0,0 +1,13 @@ +const ROOT_SOURCE: &str = include_str!("../src/root.rs"); + +#[test] +fn public_score_publication_routes_through_success_durability_boundary() { + assert!( + ROOT_SOURCE.contains("mod score_publication;"), + "the crate root must own an explicit successful-publication durability boundary" + ); + assert!( + ROOT_SOURCE.contains("pub use score_publication::publish_score_pdf_attachment;"), + "external callers must receive the durability-wrapped publication API" + ); +} From 76efc35308c96ad791d4e1c2ac4c0c26187e8f3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:04:00 +0900 Subject: [PATCH 087/166] fix(score): gate publication success on metadata durability --- apps/desktop/core/src/score_publication.rs | 159 +++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 apps/desktop/core/src/score_publication.rs diff --git a/apps/desktop/core/src/score_publication.rs b/apps/desktop/core/src/score_publication.rs new file mode 100644 index 000000000..be64d3db1 --- /dev/null +++ b/apps/desktop/core/src/score_publication.rs @@ -0,0 +1,159 @@ +use crate::score_recovery; +use std::path::Path; + +const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; + +#[cfg(target_os = "macos")] +const SYNC_VOLUME_FULLSYNC: i32 = 0x01; +#[cfg(target_os = "macos")] +const SYNC_VOLUME_WAIT: i32 = 0x02; + +#[cfg(target_os = "macos")] +extern "C" { + fn fsync_volume_np(fd: i32, flags: i32) -> i32; +} + +#[cfg(target_os = "macos")] +fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { + use std::{fs::File, os::fd::AsRawFd}; + + let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !directory + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())? + .is_dir() + { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + // Darwin's volume sync API accepts a directory descriptor. FULLSYNC + WAIT + // asks the filesystem and device to make the just-published directory + // metadata durable before BandScope reports attachment success. + let result = unsafe { + fsync_volume_np( + directory.as_raw_fd(), + SYNC_VOLUME_FULLSYNC | SYNC_VOLUME_WAIT, + ) + }; + if result != 0 { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { + use std::fs::File; + + let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !directory + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())? + .is_dir() + { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + directory + .sync_all() + .map_err(|_| SCORE_ATTACH_ERROR.to_string()) +} + +#[cfg(windows)] +fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { + // The lower Score Storage publisher already synchronizes the staged file + // before creating the destination link. Windows directory-entry durability + // still needs a separately verified native contract; do not fabricate one + // by treating a file flush as a directory metadata barrier. + Ok(()) +} + +#[cfg(all(not(unix), not(windows)))] +fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { + Err(SCORE_ATTACH_ERROR.to_string()) +} + +/// Publish a score PDF and complete the supported-platform success durability +/// barrier before attachment success is returned to the application. +/// +/// The underlying Score Storage owner still performs bounded copy, stage sync, +/// no-clobber hard-link publication, identity attestation and interrupted-write +/// recovery. This boundary adds the missing successful-return metadata barrier: +/// on macOS it performs a blocking full-volume sync using the app-owned scores +/// directory descriptor; on other Unix systems it synchronizes that directory. +/// If the barrier fails, publication is reported as failed and any already +/// published object remains recoverable through Score Storage inventory rather +/// than being guessed away. +/// +/// Windows deliberately retains the existing staged-file synchronization until +/// a directory-metadata durability primitive is verified on the supported +/// packaged runtime. No Windows power-loss durability claim is made here. +/// +/// Security Notes: the API exposes neither the selected source path nor the +/// workspace path in errors. It does not mutate Project Persistence metadata and +/// does not infer buyer intent from the existence of a published object. +pub fn publish_score_pdf_attachment( + source: &Path, + scores_root: &Path, + score_id: &str, +) -> Result { + let written = score_recovery::publish_score_pdf_attachment(source, scores_root, score_id)?; + sync_successful_publication_metadata(scores_root)?; + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + + 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-score-publication-{name}-{suffix}")) + } + + #[test] + fn public_publication_finishes_supported_success_barrier_before_return() { + let root = unique_test_dir("success-durability"); + fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + fs::write(&source, b"%PDF-1.7\ndurable-publication") + .expect("score fixture should be written"); + + let written = publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect("publication should complete its supported durability barrier"); + + assert_eq!(written, b"%PDF-1.7\ndurable-publication".len() as u64); + assert_eq!( + fs::read(root.join(format!("{SCORE_ID}.pdf"))) + .expect("published score should remain readable"), + b"%PDF-1.7\ndurable-publication" + ); + assert!( + !root.join(format!(".score-{SCORE_ID}.stage")).exists(), + "successful publication should retire its temporary stage" + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn metadata_barrier_rejects_a_non_directory_authority() { + let root = unique_test_dir("non-directory"); + fs::write(&root, b"not-a-directory").expect("fixture should be written"); + + let error = sync_successful_publication_metadata(&root) + .expect_err("a file cannot stand in for the score workspace directory"); + + assert_eq!(error, SCORE_ATTACH_ERROR); + let _ = fs::remove_file(root); + } +} From 18d74a488b67a0c2d956cb37cbea2a57860aeaca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:05:10 +0900 Subject: [PATCH 088/166] fix(score): route publication through durability wrapper --- apps/desktop/core/src/root.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index ec72517b0..b4ee485a6 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -9,6 +9,7 @@ mod runtime_core; mod content_sha256; mod score_pdf; +mod score_publication; mod score_recovery; mod score_retention; mod score_storage; @@ -16,10 +17,10 @@ mod score_storage; pub use content_sha256::sha256_hex_reader; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; +pub use score_publication::publish_score_pdf_attachment; pub use score_recovery::{ inventory_published_score_pdf_ids, inventory_published_score_pdf_receipts, - publish_score_pdf_attachment, remove_score_pdf_attachment_if_receipt_matches, - PublishedScorePdfReceipt, + remove_score_pdf_attachment_if_receipt_matches, PublishedScorePdfReceipt, }; pub use score_retention::resolve_score_pdf_for_removal; -pub use score_storage::remove_score_pdf_attachment; \ No newline at end of file +pub use score_storage::remove_score_pdf_attachment; From e208821affce548148c5ddb269d4b892a25470bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:05:29 +0900 Subject: [PATCH 089/166] ci(score): own publication durability regressions --- .github/workflows/score-storage-native.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index adf457572..a344a113d 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -11,6 +11,7 @@ on: - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/content_sha256.rs" - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_publication.rs" - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" @@ -21,6 +22,7 @@ on: - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" - "docs/traceability/score-attachment-post-link-recovery.md" + - "docs/traceability/score-attachment-success-durability.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -32,6 +34,7 @@ on: - "apps/desktop/core/src/lib.rs" - "apps/desktop/core/src/content_sha256.rs" - "apps/desktop/core/src/score_pdf.rs" + - "apps/desktop/core/src/score_publication.rs" - "apps/desktop/core/src/score_recovery.rs" - "apps/desktop/core/src/score_retention.rs" - "apps/desktop/core/src/score_storage.rs" @@ -42,6 +45,7 @@ on: - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" - "docs/traceability/score-attachment-post-link-recovery.md" + - "docs/traceability/score-attachment-success-durability.md" - ".github/workflows/score-storage-native.yml" permissions: @@ -62,7 +66,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d199908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -77,10 +81,13 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --lib score_recovery::tests:: + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --lib score_publication::tests:: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -90,4 +97,5 @@ jobs: --test score_pdf_restart_readback --test score_pdf_recovery_inventory --test score_pdf_recovery_object_receipt - --test score_pdf_attachment_wiring \ No newline at end of file + --test score_pdf_success_durability + --test score_pdf_attachment_wiring From 0c99ef61a44d8c7ccdd403a010811bb3580a0090 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:06:00 +0900 Subject: [PATCH 090/166] fix(ci): restore exact checkout action pin --- .github/workflows/score-storage-native.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index a344a113d..5e2b33452 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -66,7 +66,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d199908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} From 84e51ad0faebdd2adbd18a19d1e865c61e240a26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:06:47 +0900 Subject: [PATCH 091/166] docs(score): trace successful-publication durability boundary --- .../score-attachment-success-durability.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/traceability/score-attachment-success-durability.md diff --git a/docs/traceability/score-attachment-success-durability.md b/docs/traceability/score-attachment-success-durability.md new file mode 100644 index 000000000..b2a3defec --- /dev/null +++ b/docs/traceability/score-attachment-success-durability.md @@ -0,0 +1,76 @@ +# Score attachment successful-publication durability + +## Problem + +Score Storage already synchronized the private staging file before no-clobber hard-link publication and could recover stage-only or verified post-link process death. That made the object lifecycle crash-safe at the process boundary, but it did not establish a separate successful-return durability barrier for the directory entry itself. + +The distinction matters for the buyer contract. POSIX directory operations are atomic and serializable but are not necessarily durable; an application that needs a directory modification to survive a crash must synchronize the directory. Returning attachment success before that barrier could therefore acknowledge bytes that a sudden power loss may not leave durably named in the score workspace. + +This is Score Storage truth. Project Persistence must not compensate by probing score paths or copying storage implementation. + +## Constraints + +- Keep the existing descriptor-bounded 25 MiB copy, PDF validation, private staging, no-clobber publication, object-identity attestation, cross-process lease and restart recovery unchanged. +- A durability failure after publication must not guess that the published object is absent or safe to delete. The existing restart inventory remains the recovery authority. +- Errors remain path- and payload-safe. +- Do not claim a Windows directory-entry power-loss guarantee without a verified supported-runtime primitive and exact-head evidence. +- Do not introduce a second publication implementation at the Tauri boundary; the crate-root publication API remains the single application-facing contract. + +## Alternatives considered + +### Treat staged-file `sync_all` as sufficient + +Rejected. Synchronizing the file contents does not, by itself, establish the separate directory modification durability requested by the successful-return contract on POSIX systems. + +### Call a process-wide or asynchronous `sync` + +Rejected. A global sync is broader than the Score Storage bounded context and may return before all buffers have reached persistent media on Darwin. It also obscures which namespace mutation the product is waiting for. + +### Delete the published object when the metadata barrier fails + +Rejected. The object may already be a valid durable recovery candidate. Destructive guessing would turn an uncertain durability result into possible buyer-data loss. + +### Claim equivalent Windows durability using only the staged-file flush + +Rejected. The lower publisher synchronizes the staged file before the link is created, but that is not evidence that the directory-entry update has reached persistent media. The Windows directory-metadata contract remains an explicit follow-up rather than a fabricated cross-platform claim. + +## Selected contract + +`score_publication::publish_score_pdf_attachment` wraps the existing Score Storage recovery/publication owner and adds a successful-return metadata barrier after lower-level publication succeeds: + +- **macOS:** open the app-owned score directory and call `fsync_volume_np` with `SYNC_VOLUME_FULLSYNC | SYNC_VOLUME_WAIT`. The call is blocking and failure prevents attachment success from being returned. +- **other Unix:** open the app-owned score directory and call `File::sync_all()` before returning success. +- **Windows:** retain the existing synchronized staging-file contract and make no new directory-entry power-loss claim until the native owner gains a separately verified Windows barrier. + +The crate root exports this wrapper as the application-facing `publish_score_pdf_attachment`; the lower recovery publisher remains an internal owner service used by the wrapper. + +If the post-publication barrier fails, the caller receives the existing generic attachment failure. Score Storage does not delete an already-published object in response. A subsequent inventory/recovery pass determines actual object truth. + +## Test and CI ownership + +Source-level RED `49e886f4d43cfacab3bc891b1f7a8ef02089ce2e` added `score_pdf_success_durability.rs`, which requires an explicit successful-publication durability module and public crate-root routing. A causal descendant followed before terminal hosted evidence, so this is source RED only. + +Implementation lineage: + +- `76efc35308c96ad791d4e1c2ac4c0c26187e8f3e` adds the supported-platform metadata barrier and focused unit tests. +- `18d74a488b67a0c2d956cb37cbea2a57860aeaca` routes the crate-root publication API through that barrier. +- `e208821affce548148c5ddb269d4b892a25470bf` first added the new owner inputs to `score-storage-native`, but accidentally malformed the existing exact `actions/checkout` pin while editing the workflow. +- `0c99ef61a44d8c7ccdd403a010811bb3580a0090` immediately restores the canonical checkout SHA; the malformed intermediate workflow is not evidence and must not be used as a run target. + +The owner workflow must run the `score_publication` unit tests and `score_pdf_success_durability` integration contract on macOS 15 and Windows Server 2025. A macOS GREEN is required before the Darwin FFI symbol/behavior is claimed as executable. Windows GREEN verifies that the deliberately narrower existing Windows path remains intact; it does not turn the unimplemented Windows directory-entry power-loss barrier into a claim. + +## Claim boundary + +This repair improves **successful-return durability** for macOS and other Unix systems. It does not prove power-loss behavior for every filesystem, storage controller, virtualization layer or packaged executable. It does not prove authenticity or provenance of the PDF. It does not make Project Persistence metadata durable and does not decide Recover / Preserve / Discard intent. + +The remaining acceptance work is deliberate fault injection and packaged evidence at the meaningful boundaries: cancellation, disk-full, permission failure and power loss; Windows directory-entry durability; old unleased-build coexistence; Unix residual basename race; project deletion/rollback; and cross-owner recovery UX. + +## References + +Apple Inc. (n.d.). *VNOP_FSYNC*. Apple Developer Documentation. https://developer.apple.com/documentation/kernel/1586212-vnop_fsync + +Microsoft. (2025). *NtFlushBuffersFileEx function (ntifs.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntflushbuffersfileex + +Microsoft. (2021). *FlushFileBuffers function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers + +The Open Group. (2024). *Rationale for Base Definitions, Issue 8*. POSIX.1-2024. https://pubs.opengroup.org/onlinepubs/9799919799/xrat/V4_xbd_chap01.html From 2804d00776a8e829d2c5c661806c6498d496631c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:08:48 +0900 Subject: [PATCH 092/166] fix(score): keep metadata barrier inside storage lease --- apps/desktop/core/src/score_publication.rs | 71 ++++------------------ 1 file changed, 11 insertions(+), 60 deletions(-) diff --git a/apps/desktop/core/src/score_publication.rs b/apps/desktop/core/src/score_publication.rs index be64d3db1..a7e4ec2bc 100644 --- a/apps/desktop/core/src/score_publication.rs +++ b/apps/desktop/core/src/score_publication.rs @@ -1,4 +1,3 @@ -use crate::score_recovery; use std::path::Path; const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; @@ -13,8 +12,15 @@ extern "C" { fn fsync_volume_np(fd: i32, flags: i32) -> i32; } +/// Complete the successful-publication metadata durability barrier for the +/// supported platform while the Score Storage workspace lease is still held. +/// +/// This function does not publish or remove any object. Its caller owns the +/// lifecycle transaction and must keep the Score Storage lease alive across the +/// lower-level publication and this barrier so another current-contract process +/// cannot mutate the workspace in between. #[cfg(target_os = "macos")] -fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { +pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { use std::{fs::File, os::fd::AsRawFd}; let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; @@ -42,7 +48,7 @@ fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String } #[cfg(all(unix, not(target_os = "macos")))] -fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { +pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { use std::fs::File; let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; @@ -59,7 +65,7 @@ fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String } #[cfg(windows)] -fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { +pub(crate) fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { // The lower Score Storage publisher already synchronizes the staged file // before creating the destination link. Windows directory-entry durability // still needs a separately verified native contract; do not fabricate one @@ -68,39 +74,10 @@ fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), Strin } #[cfg(all(not(unix), not(windows)))] -fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { +pub(crate) fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { Err(SCORE_ATTACH_ERROR.to_string()) } -/// Publish a score PDF and complete the supported-platform success durability -/// barrier before attachment success is returned to the application. -/// -/// The underlying Score Storage owner still performs bounded copy, stage sync, -/// no-clobber hard-link publication, identity attestation and interrupted-write -/// recovery. This boundary adds the missing successful-return metadata barrier: -/// on macOS it performs a blocking full-volume sync using the app-owned scores -/// directory descriptor; on other Unix systems it synchronizes that directory. -/// If the barrier fails, publication is reported as failed and any already -/// published object remains recoverable through Score Storage inventory rather -/// than being guessed away. -/// -/// Windows deliberately retains the existing staged-file synchronization until -/// a directory-metadata durability primitive is verified on the supported -/// packaged runtime. No Windows power-loss durability claim is made here. -/// -/// Security Notes: the API exposes neither the selected source path nor the -/// workspace path in errors. It does not mutate Project Persistence metadata and -/// does not infer buyer intent from the existence of a published object. -pub fn publish_score_pdf_attachment( - source: &Path, - scores_root: &Path, - score_id: &str, -) -> Result { - let written = score_recovery::publish_score_pdf_attachment(source, scores_root, score_id)?; - sync_successful_publication_metadata(scores_root)?; - Ok(written) -} - #[cfg(test)] mod tests { use super::*; @@ -110,8 +87,6 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; - fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -120,30 +95,6 @@ mod tests { std::env::temp_dir().join(format!("bandscope-score-publication-{name}-{suffix}")) } - #[test] - fn public_publication_finishes_supported_success_barrier_before_return() { - let root = unique_test_dir("success-durability"); - fs::create_dir_all(&root).expect("score root should be created"); - let source = root.join("selected.pdf"); - fs::write(&source, b"%PDF-1.7\ndurable-publication") - .expect("score fixture should be written"); - - let written = publish_score_pdf_attachment(&source, &root, SCORE_ID) - .expect("publication should complete its supported durability barrier"); - - assert_eq!(written, b"%PDF-1.7\ndurable-publication".len() as u64); - assert_eq!( - fs::read(root.join(format!("{SCORE_ID}.pdf"))) - .expect("published score should remain readable"), - b"%PDF-1.7\ndurable-publication" - ); - assert!( - !root.join(format!(".score-{SCORE_ID}.stage")).exists(), - "successful publication should retire its temporary stage" - ); - let _ = fs::remove_dir_all(root); - } - #[cfg(unix)] #[test] fn metadata_barrier_rejects_a_non_directory_authority() { From 542b46d3fdb82499bcbe56b4395b3c4e4e6addff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:11:17 +0900 Subject: [PATCH 093/166] fix(score): hold storage lease through durability barrier --- apps/desktop/core/src/score_recovery.rs | 104 ++++++++++++++++++------ 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 1ff582178..09f332768 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -1,6 +1,6 @@ use crate::{ - is_valid_score_id, read_validated_score_pdf, resolve_existing_score_pdf, score_storage, - score_storage::remove_score_pdf_attachment, sha256_hex_reader, + is_valid_score_id, read_validated_score_pdf, resolve_existing_score_pdf, score_publication, + score_storage, score_storage::remove_score_pdf_attachment, sha256_hex_reader, }; use std::{ fs::{self, File, OpenOptions}, @@ -361,38 +361,61 @@ pub fn remove_score_pdf_attachment_if_receipt_matches( Ok(true) } -/// Publish one score attachment after recovering process-abandoned staging. -/// -/// The workspace lease spans recovery and the complete lower-level publication -/// so another BandScope process using this contract cannot have a live writer -/// mistaken for stale staging. A crash releases the OS lease automatically; -/// the next operation removes a reserved `.score-.stage` regular file -/// when no destination exists. If a synchronized destination also exists and -/// both names contain the same validated bytes, recovery retires only the stage -/// alias and keeps the destination as a recovery candidate for Project -/// Persistence. Different stage/destination bytes remain ambiguous and fail -/// closed. -/// -/// Security Notes: malformed names are ignored because they are outside the -/// owned staging namespace. Reserved symlink/reparse/non-regular entries, -/// lock acquisition failure, unreadable directory state, or a stage plus a -/// content-different destination fail closed. Errors contain neither absolute -/// paths nor PDF bytes. This recovery covers current-contract stage-only and -/// verified post-link interruption states; it does not claim compatibility -/// with an older concurrently running BandScope build that never acquired the -/// lease. -pub fn publish_score_pdf_attachment( +fn publish_score_pdf_attachment_with_metadata_sync( source: &Path, scores_root: &Path, score_id: &str, -) -> Result { + sync_metadata: F, +) -> Result +where + F: FnOnce(&Path) -> Result<(), String>, +{ if !is_valid_score_id(score_id) { return Err(SCORE_ATTACH_ERROR.to_string()); } let lease = acquire_score_workspace_lease(scores_root)?; recover_abandoned_score_stages(scores_root, &lease)?; - score_storage::publish_score_pdf_attachment(source, scores_root, score_id) + let written = score_storage::publish_score_pdf_attachment(source, scores_root, score_id)?; + sync_metadata(scores_root)?; + Ok(written) +} + +/// Publish one score attachment after recovering process-abandoned staging. +/// +/// The workspace lease spans recovery, the complete lower-level publication, +/// and the supported-platform successful-return metadata durability barrier. +/// Another BandScope process using this contract therefore cannot mutate the +/// score workspace between destination publication and that barrier. A crash +/// releases the OS lease automatically; the next operation removes a reserved +/// `.score-.stage` regular file when no destination exists. If a +/// synchronized destination also exists and both names contain the same +/// validated bytes, recovery retires only the stage alias and keeps the +/// destination as a recovery candidate for Project Persistence. Different +/// stage/destination bytes remain ambiguous and fail closed. +/// +/// Security Notes: malformed names are ignored because they are outside the +/// owned staging namespace. Reserved symlink/reparse/non-regular entries, lock +/// acquisition failure, unreadable directory state, a stage plus a +/// content-different destination, or a supported-platform metadata barrier +/// failure fail closed. Errors contain neither absolute paths nor PDF bytes. A +/// barrier failure after publication does not guess-delete the object; later +/// inventory/recovery determines actual storage truth. Windows retains the +/// staged-file sync contract but does not yet claim directory-entry power-loss +/// durability. This recovery covers current-contract interruption states and +/// does not claim compatibility with an older concurrently running BandScope +/// build that never acquired the lease. +pub fn publish_score_pdf_attachment( + source: &Path, + scores_root: &Path, + score_id: &str, +) -> Result { + publish_score_pdf_attachment_with_metadata_sync( + source, + scores_root, + score_id, + score_publication::sync_successful_publication_metadata, + ) } #[cfg(test)] @@ -438,6 +461,37 @@ mod tests { assert_eq!(published_score_id("6fa459ea-ee8a-4ca4-894e-db77e160355e.PDF"), None); } + #[test] + fn publication_metadata_barrier_runs_while_workspace_lease_is_held() { + let root = unique_test_dir("publication-barrier-lease"); + fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + fs::write(&source, b"%PDF-1.7\nlease-bound-barrier") + .expect("score fixture should be written"); + let mut barrier_called = false; + + let written = publish_score_pdf_attachment_with_metadata_sync( + &source, + &root, + SCORE_ID, + |scores_root| { + barrier_called = true; + assert_eq!( + acquire_score_workspace_lease(scores_root).err().as_deref(), + Some(SCORE_RECOVERY_ERROR), + "metadata sync must execute before the publication lease is released" + ); + Ok(()) + }, + ) + .expect("lease-bound publication should succeed"); + + assert!(barrier_called); + assert_eq!(written, b"%PDF-1.7\nlease-bound-barrier".len() as u64); + assert!(root.join(format!("{SCORE_ID}.pdf")).exists()); + let _ = fs::remove_dir_all(root); + } + #[test] fn workspace_lease_child() { if std::env::var_os(LEASE_CHILD_ENV).is_none() { From 8371642ecae5e40907c8743bf9c6cb76142ea345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:11:30 +0900 Subject: [PATCH 094/166] fix(score): keep publication transaction in storage owner --- apps/desktop/core/src/root.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index b4ee485a6..9eb3a530a 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -17,10 +17,10 @@ mod score_storage; pub use content_sha256::sha256_hex_reader; pub use runtime_core::*; pub use score_pdf::read_validated_score_pdf; -pub use score_publication::publish_score_pdf_attachment; pub use score_recovery::{ inventory_published_score_pdf_ids, inventory_published_score_pdf_receipts, - remove_score_pdf_attachment_if_receipt_matches, PublishedScorePdfReceipt, + publish_score_pdf_attachment, remove_score_pdf_attachment_if_receipt_matches, + PublishedScorePdfReceipt, }; pub use score_retention::resolve_score_pdf_for_removal; pub use score_storage::remove_score_pdf_attachment; From f923474965b2e9ed163af6bf04de928b10048d12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:11:49 +0900 Subject: [PATCH 095/166] test(score): bind durability barrier to storage lease --- .../tests/score_pdf_success_durability.rs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_success_durability.rs b/apps/desktop/core/tests/score_pdf_success_durability.rs index de3b35e30..45a220f42 100644 --- a/apps/desktop/core/tests/score_pdf_success_durability.rs +++ b/apps/desktop/core/tests/score_pdf_success_durability.rs @@ -1,13 +1,33 @@ const ROOT_SOURCE: &str = include_str!("../src/root.rs"); +const RECOVERY_SOURCE: &str = include_str!("../src/score_recovery.rs"); #[test] -fn public_score_publication_routes_through_success_durability_boundary() { +fn public_score_publication_routes_through_lease_bound_success_durability_boundary() { assert!( ROOT_SOURCE.contains("mod score_publication;"), "the crate root must own an explicit successful-publication durability boundary" ); assert!( - ROOT_SOURCE.contains("pub use score_publication::publish_score_pdf_attachment;"), - "external callers must receive the durability-wrapped publication API" + ROOT_SOURCE.contains("publish_score_pdf_attachment"), + "the public crate root must continue exporting the canonical publication API" + ); + + let lease = RECOVERY_SOURCE + .find("let lease = acquire_score_workspace_lease(scores_root)?;") + .expect("publication must acquire the Score Storage workspace lease"); + let publication = RECOVERY_SOURCE + .find("let written = score_storage::publish_score_pdf_attachment(source, scores_root, score_id)?;") + .expect("publication must use the canonical lower Score Storage publisher"); + let barrier = RECOVERY_SOURCE + .find("sync_metadata(scores_root)?;") + .expect("publication must execute the metadata durability barrier"); + + assert!( + lease < publication && publication < barrier, + "the successful-return durability barrier must execute after publication while the lease remains in scope" + ); + assert!( + RECOVERY_SOURCE.contains("score_publication::sync_successful_publication_metadata"), + "the public publication path must supply the platform durability barrier" ); } From 800d775b1c84c569fbbdf4ec184c3ae52527c275 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:12:20 +0900 Subject: [PATCH 096/166] docs(score): bind durability barrier to storage lease --- .../score-attachment-success-durability.md | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/traceability/score-attachment-success-durability.md b/docs/traceability/score-attachment-success-durability.md index b2a3defec..f05c2ca58 100644 --- a/docs/traceability/score-attachment-success-durability.md +++ b/docs/traceability/score-attachment-success-durability.md @@ -11,6 +11,7 @@ This is Score Storage truth. Project Persistence must not compensate by probing ## Constraints - Keep the existing descriptor-bounded 25 MiB copy, PDF validation, private staging, no-clobber publication, object-identity attestation, cross-process lease and restart recovery unchanged. +- The publication lease must cover the metadata durability barrier. Releasing it between link publication and synchronization would let another current-contract process mutate the same workspace before the success transaction reaches its durability point. - A durability failure after publication must not guess that the published object is absent or safe to delete. The existing restart inventory remains the recovery authority. - Errors remain path- and payload-safe. - Do not claim a Windows directory-entry power-loss guarantee without a verified supported-runtime primitive and exact-head evidence. @@ -22,6 +23,10 @@ This is Score Storage truth. Project Persistence must not compensate by probing Rejected. Synchronizing the file contents does not, by itself, establish the separate directory modification durability requested by the successful-return contract on POSIX systems. +### Release the Score Storage lease and synchronize in an outer wrapper + +Rejected after self-review. Another current-contract process could acquire the lease after lower-level publication and before the outer metadata barrier. Successful publication must therefore keep the existing storage transaction lease alive through the durability barrier. + ### Call a process-wide or asynchronous `sync` Rejected. A global sync is broader than the Score Storage bounded context and may return before all buffers have reached persistent media on Darwin. It also obscures which namespace mutation the product is waiting for. @@ -36,28 +41,35 @@ Rejected. The lower publisher synchronizes the staged file before the link is cr ## Selected contract -`score_publication::publish_score_pdf_attachment` wraps the existing Score Storage recovery/publication owner and adds a successful-return metadata barrier after lower-level publication succeeds: +`score_recovery::publish_score_pdf_attachment` remains the single application-facing publication transaction. It holds the existing `ScoreWorkspaceLease` across abandoned-stage recovery, lower Score Storage publication and the successful-return metadata barrier: -- **macOS:** open the app-owned score directory and call `fsync_volume_np` with `SYNC_VOLUME_FULLSYNC | SYNC_VOLUME_WAIT`. The call is blocking and failure prevents attachment success from being returned. +- **macOS:** open the app-owned score directory and call `fsync_volume_np` with `SYNC_VOLUME_FULLSYNC | SYNC_VOLUME_WAIT`. Darwin documents this as syncing data and metadata for the filesystem containing the descriptor to disk hardware and waiting for completion. - **other Unix:** open the app-owned score directory and call `File::sync_all()` before returning success. - **Windows:** retain the existing synchronized staging-file contract and make no new directory-entry power-loss claim until the native owner gains a separately verified Windows barrier. -The crate root exports this wrapper as the application-facing `publish_score_pdf_attachment`; the lower recovery publisher remains an internal owner service used by the wrapper. +The platform-specific primitive is isolated in `score_publication`, but that module does not own the transaction. `score_recovery` invokes it before the workspace lease drops. A focused unit regression attempts to reacquire the lease from inside the injected metadata barrier and requires that reacquisition to fail, proving the barrier executes inside the owner transaction. If the post-publication barrier fails, the caller receives the existing generic attachment failure. Score Storage does not delete an already-published object in response. A subsequent inventory/recovery pass determines actual object truth. ## Test and CI ownership -Source-level RED `49e886f4d43cfacab3bc891b1f7a8ef02089ce2e` added `score_pdf_success_durability.rs`, which requires an explicit successful-publication durability module and public crate-root routing. A causal descendant followed before terminal hosted evidence, so this is source RED only. +Source-level RED `49e886f4d43cfacab3bc891b1f7a8ef02089ce2e` added `score_pdf_success_durability.rs`, requiring an explicit successful-publication durability boundary. A causal descendant followed before terminal hosted evidence, so this is source RED only. Implementation lineage: -- `76efc35308c96ad791d4e1c2ac4c0c26187e8f3e` adds the supported-platform metadata barrier and focused unit tests. -- `18d74a488b67a0c2d956cb37cbea2a57860aeaca` routes the crate-root publication API through that barrier. +- `76efc35308c96ad791d4e1c2ac4c0c26187e8f3e` introduced the supported-platform metadata primitive. +- `18d74a488b67a0c2d956cb37cbea2a57860aeaca` initially routed publication through an outer wrapper. +- self-review found that the outer wrapper released the Score Storage lease before synchronization, leaving a narrow inter-process mutation interval; that intermediate design is not the accepted transaction contract. +- `2804d00776a8e829d2c5c661806c6498d496631c` narrowed `score_publication` to the platform durability primitive. +- `542b46d3fdb82499bcbe56b4395b3c4e4e6addff` moved the durability call inside `score_recovery` while the workspace lease is alive and added a lease-continuity regression. +- `8371642ecae5e40907c8743bf9c6cb76142ea345` restores the crate-root API to the canonical Score Storage transaction. +- `f923474965b2e9ed163af6bf04de928b10048d12` updates the integration contract to require publication -> durability ordering inside that transaction. - `e208821affce548148c5ddb269d4b892a25470bf` first added the new owner inputs to `score-storage-native`, but accidentally malformed the existing exact `actions/checkout` pin while editing the workflow. - `0c99ef61a44d8c7ccdd403a010811bb3580a0090` immediately restores the canonical checkout SHA; the malformed intermediate workflow is not evidence and must not be used as a run target. -The owner workflow must run the `score_publication` unit tests and `score_pdf_success_durability` integration contract on macOS 15 and Windows Server 2025. A macOS GREEN is required before the Darwin FFI symbol/behavior is claimed as executable. Windows GREEN verifies that the deliberately narrower existing Windows path remains intact; it does not turn the unimplemented Windows directory-entry power-loss barrier into a claim. +The owner workflow runs `score_publication` and `score_recovery` unit tests plus `score_pdf_success_durability` on macOS 15 and Windows Server 2025. The first descendant before the lease self-review, `84e51ad0faebdd2adbd18a19d1e865c61e240a26`, completed its macOS owner job successfully, which proved the Darwin FFI symbol/flags were executable on the hosted macOS 15 lane, but that predecessor GREEN does not transfer to the later lease-bound head. + +A fresh exact-head macOS GREEN is required for the accepted lease-bound design. Windows GREEN verifies that the deliberately narrower existing Windows path remains intact; it does not turn the unimplemented Windows directory-entry power-loss barrier into a claim. ## Claim boundary @@ -69,6 +81,8 @@ The remaining acceptance work is deliberate fault injection and packaged evidenc Apple Inc. (n.d.). *VNOP_FSYNC*. Apple Developer Documentation. https://developer.apple.com/documentation/kernel/1586212-vnop_fsync +Apple Inc. (n.d.). *sync_volume_np(3): Sync a mounted filesystem*. macOS system manual. The current Xcode man-page rendering is mirrored at https://keith.github.io/xcode-man-pages/sync_volume_np.3.html + Microsoft. (2025). *NtFlushBuffersFileEx function (ntifs.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntflushbuffersfileex Microsoft. (2021). *FlushFileBuffers function (fileapi.h)*. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers From 92b34e5c0288227652673d0614cc6fae8b3db5b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:29:13 +0900 Subject: [PATCH 097/166] test(score): expose Unix stage cleanup pathname race --- ...ore_storage_unix_stage_cleanup_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs diff --git a/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs b/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs new file mode 100644 index 000000000..a1912c2fa --- /dev/null +++ b/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs @@ -0,0 +1,30 @@ +const SCORE_STORAGE_SOURCE: &str = include_str!("../src/score_storage.rs"); + +#[test] +fn unix_owned_stage_cleanup_uses_descriptor_relative_identity_recheck_before_unlink() { + let unix_cleanup_start = SCORE_STORAGE_SOURCE + .find("#[cfg(unix)]\nfn remove_owned_stage") + .expect("Unix owned-stage cleanup must remain explicit"); + let windows_cleanup_start = SCORE_STORAGE_SOURCE[unix_cleanup_start..] + .find("#[cfg(windows)]\nfn remove_owned_stage_with_hook") + .map(|offset| unix_cleanup_start + offset) + .expect("Windows cleanup boundary must follow the Unix implementation"); + let unix_cleanup = &SCORE_STORAGE_SOURCE[unix_cleanup_start..windows_cleanup_start]; + + assert!( + unix_cleanup.contains("open_score_entry_at"), + "Unix stage cleanup must reopen the reserved basename beneath a pinned parent descriptor" + ); + assert!( + unix_cleanup.contains("stage_identity(¤t_file)"), + "Unix stage cleanup must revalidate the current object identity immediately before unlink" + ); + assert!( + unix_cleanup.contains("unlinkat"), + "Unix stage cleanup must unlink through the pinned parent descriptor rather than a pathname" + ); + assert!( + !unix_cleanup.contains("fs::remove_file(path)"), + "Unix stage cleanup must not fall back to pathname-only deletion after identity validation" + ); +} From 46ff8f92c7f1983fba0805479e7cfe80dfeaebfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:30:45 +0900 Subject: [PATCH 098/166] fix(score): pin Unix stage cleanup authority --- apps/desktop/core/src/score_storage.rs | 90 ++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index 7b7d5ebc0..cf946b0e3 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -326,14 +326,61 @@ fn stage_identity(_file: &File) -> Result { } #[cfg(unix)] -fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { - use std::os::unix::fs::MetadataExt; +fn remove_owned_stage_with_hook( + path: &Path, + expected: StageIdentity, + before_unlink: F, +) -> Result<(), String> +where + F: FnOnce(), +{ + use std::{ffi::CString, os::fd::AsRawFd, os::unix::ffi::OsStrExt}; - let current = fs::symlink_metadata(path).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; - if !current.is_file() || expected.device != current.dev() || expected.inode != current.ino() { + let parent = path + .parent() + .ok_or_else(|| SCORE_ATTACH_ERROR.to_string())?; + let file_name = path + .file_name() + .ok_or_else(|| SCORE_ATTACH_ERROR.to_string())?; + let parent_file = File::open(parent).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !parent_file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())? + .is_dir() + { return Err(SCORE_ATTACH_ERROR.to_string()); } - fs::remove_file(path).map_err(|_| SCORE_ATTACH_ERROR.to_string()) + let name = CString::new(file_name.as_bytes()).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let target_file = + open_score_entry_at(&parent_file, &name).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let target_metadata = target_file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !target_metadata.is_file() || stage_identity(&target_file)? != expected { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + before_unlink(); + + let current_file = + open_score_entry_at(&parent_file, &name).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + let current_metadata = current_file + .metadata() + .map_err(|_| SCORE_ATTACH_ERROR.to_string())?; + if !current_metadata.is_file() || stage_identity(¤t_file)? != expected { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + + let result = unsafe { unlinkat(parent_file.as_raw_fd(), name.as_ptr(), 0) }; + if result != 0 { + return Err(SCORE_ATTACH_ERROR.to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn remove_owned_stage(path: &Path, expected: StageIdentity) -> Result<(), String> { + remove_owned_stage_with_hook(path, expected, || {}) } #[cfg(windows)] @@ -596,7 +643,9 @@ where /// Windows, with a second check after the temporary stage alias is retired. /// /// Security Notes: errors never include the source path or PDF bytes. Unix -/// cleanup compares device/inode identity captured from the open stage. Windows +/// stage cleanup pins the parent directory, opens the reserved basename with +/// `O_NOFOLLOW`, rechecks device/inode identity, and unlinks through `unlinkat`; +/// the narrow final identity-check-to-`unlinkat` race remains explicit. Windows /// opens the exact stage object with DELETE authority, verifies its volume plus /// 128-bit file id against the captured identity, then marks that same handle /// for deletion so a late pathname replacement cannot redirect cleanup. Final @@ -743,6 +792,35 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[cfg(unix)] + #[test] + fn unix_stage_cleanup_preserves_replacement_before_descriptor_relative_unlink() { + let root = unique_test_dir("score-stage-cleanup-unix-replacement"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join("stage.pdf"); + let moved = root.join("owned-before-replacement.pdf"); + let stage_file = create_private_stage(&stage).expect("owned stage should be created"); + let expected = stage_identity(&stage_file).expect("owned stage identity should be captured"); + drop(stage_file); + + let error = remove_owned_stage_with_hook(&stage, expected, || { + fs::rename(&stage, &moved).expect("owned stage should move after identity check"); + fs::write(&stage, b"foreign replacement").expect("foreign replacement should be written"); + }) + .expect_err("identity mismatch must fail closed before stage unlinkat"); + + assert_eq!(error, SCORE_ATTACH_ERROR); + assert_eq!( + fs::read(&stage).expect("foreign replacement must survive cleanup"), + b"foreign replacement" + ); + assert_eq!( + fs::read(&moved).expect("owned stage must survive failed cleanup"), + b"" + ); + let _ = fs::remove_dir_all(root); + } + #[cfg(windows)] #[test] fn windows_delete_marks_open_file_object_not_replacement_path() { From a4b753f478a14cb3a011178286fc54343de64d37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:31:13 +0900 Subject: [PATCH 099/166] test(score): route Unix stage cleanup contract through owner CI --- .../score_pdf_unix_stage_cleanup_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_unix_stage_cleanup_contract.rs diff --git a/apps/desktop/core/tests/score_pdf_unix_stage_cleanup_contract.rs b/apps/desktop/core/tests/score_pdf_unix_stage_cleanup_contract.rs new file mode 100644 index 000000000..a1912c2fa --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_unix_stage_cleanup_contract.rs @@ -0,0 +1,30 @@ +const SCORE_STORAGE_SOURCE: &str = include_str!("../src/score_storage.rs"); + +#[test] +fn unix_owned_stage_cleanup_uses_descriptor_relative_identity_recheck_before_unlink() { + let unix_cleanup_start = SCORE_STORAGE_SOURCE + .find("#[cfg(unix)]\nfn remove_owned_stage") + .expect("Unix owned-stage cleanup must remain explicit"); + let windows_cleanup_start = SCORE_STORAGE_SOURCE[unix_cleanup_start..] + .find("#[cfg(windows)]\nfn remove_owned_stage_with_hook") + .map(|offset| unix_cleanup_start + offset) + .expect("Windows cleanup boundary must follow the Unix implementation"); + let unix_cleanup = &SCORE_STORAGE_SOURCE[unix_cleanup_start..windows_cleanup_start]; + + assert!( + unix_cleanup.contains("open_score_entry_at"), + "Unix stage cleanup must reopen the reserved basename beneath a pinned parent descriptor" + ); + assert!( + unix_cleanup.contains("stage_identity(¤t_file)"), + "Unix stage cleanup must revalidate the current object identity immediately before unlink" + ); + assert!( + unix_cleanup.contains("unlinkat"), + "Unix stage cleanup must unlink through the pinned parent descriptor rather than a pathname" + ); + assert!( + !unix_cleanup.contains("fs::remove_file(path)"), + "Unix stage cleanup must not fall back to pathname-only deletion after identity validation" + ); +} From 5dfea0e82c41edfb51e65f41068d4b7f9b725df0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:31:23 +0900 Subject: [PATCH 100/166] chore(score): remove unowned test path --- ...ore_storage_unix_stage_cleanup_contract.rs | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs diff --git a/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs b/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs deleted file mode 100644 index a1912c2fa..000000000 --- a/apps/desktop/core/tests/score_storage_unix_stage_cleanup_contract.rs +++ /dev/null @@ -1,30 +0,0 @@ -const SCORE_STORAGE_SOURCE: &str = include_str!("../src/score_storage.rs"); - -#[test] -fn unix_owned_stage_cleanup_uses_descriptor_relative_identity_recheck_before_unlink() { - let unix_cleanup_start = SCORE_STORAGE_SOURCE - .find("#[cfg(unix)]\nfn remove_owned_stage") - .expect("Unix owned-stage cleanup must remain explicit"); - let windows_cleanup_start = SCORE_STORAGE_SOURCE[unix_cleanup_start..] - .find("#[cfg(windows)]\nfn remove_owned_stage_with_hook") - .map(|offset| unix_cleanup_start + offset) - .expect("Windows cleanup boundary must follow the Unix implementation"); - let unix_cleanup = &SCORE_STORAGE_SOURCE[unix_cleanup_start..windows_cleanup_start]; - - assert!( - unix_cleanup.contains("open_score_entry_at"), - "Unix stage cleanup must reopen the reserved basename beneath a pinned parent descriptor" - ); - assert!( - unix_cleanup.contains("stage_identity(¤t_file)"), - "Unix stage cleanup must revalidate the current object identity immediately before unlink" - ); - assert!( - unix_cleanup.contains("unlinkat"), - "Unix stage cleanup must unlink through the pinned parent descriptor rather than a pathname" - ); - assert!( - !unix_cleanup.contains("fs::remove_file(path)"), - "Unix stage cleanup must not fall back to pathname-only deletion after identity validation" - ); -} From 6965c05f228f6a0d11ee077392e2222fcc5dcc41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:31:43 +0900 Subject: [PATCH 101/166] ci(score): execute Unix stage cleanup contract --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 5e2b33452..25ec34b36 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -87,7 +87,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -98,4 +98,5 @@ jobs: --test score_pdf_recovery_inventory --test score_pdf_recovery_object_receipt --test score_pdf_success_durability + --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring From 4224dfb9c972db027119e534a10ea293f98dc4a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:33:40 +0900 Subject: [PATCH 102/166] docs(score): trace Unix stage cleanup authority --- .../score-attachment-unix-stage-cleanup.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/traceability/score-attachment-unix-stage-cleanup.md diff --git a/docs/traceability/score-attachment-unix-stage-cleanup.md b/docs/traceability/score-attachment-unix-stage-cleanup.md new file mode 100644 index 000000000..58a9118fa --- /dev/null +++ b/docs/traceability/score-attachment-unix-stage-cleanup.md @@ -0,0 +1,55 @@ +# Unix score-stage cleanup authority + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment + +## Problem + +The lower Score Storage publisher captures the stage file's Unix device/inode identity while the staging descriptor is open. Before this repair, `remove_owned_stage` later re-read metadata through the stage pathname, compared that metadata with the captured identity, and then called `std::fs::remove_file(path)`. A replacement or ancestor-path change after the identity check could therefore redirect the destructive cleanup to a different directory entry. + +That gap affected publication failure cleanup and normal temporary-stage retirement. It is distinct from restart recovery, which already routes abandoned stage removal through the Score Storage object-deletion boundary under the workspace lease. + +## RED and causal repair + +Source RED `92b34e5c0288227652673d0614cc6fae8b3db5b3` introduced a contract requiring Unix owned-stage cleanup to use pinned-parent descriptor-relative authority rather than pathname-only deletion. No hosted RED is claimed because repair descendants followed before a terminal failing run. + +The first RED filename did not match the Score Storage owner workflow's executed test list. That test-ownership defect was repaired rather than treated as evidence: the canonical test is `apps/desktop/core/tests/score_pdf_unix_stage_cleanup_contract.rs`; the superseded unowned path was removed. + +Causal source repair `46ff8f92c7f1983fba0805479e7cfe80dfeaebfb` changes Unix stage retirement to: + +1. open and retain the parent directory descriptor; +2. derive the reserved basename without relying on the ancestor pathname again; +3. open the basename with the existing `openat(..., O_NOFOLLOW)` boundary; +4. verify regular-file shape and the captured device/inode identity; +5. immediately before deletion, reopen the basename beneath the same pinned parent and revalidate identity; +6. remove it with `unlinkat(parent_fd, basename, 0)`. + +A focused Unix unit regression replaces the pathname between the initial identity check and deletion. Cleanup must fail closed, preserve the foreign replacement, and preserve the originally owned stage object that was moved aside. + +Workflow repair `6965c05f228f6a0d11ee077392e2222fcc5dcc41` adds the focused integration contract to the explicit `score-storage-native` test invocation. The earlier path-matched-but-not-executed state is not test evidence. + +## Decision + +Use the same descriptor-relative Unix authority model already used by published-score deletion instead of inventing a second cleanup primitive. This reduces duplicated security logic and keeps Score Storage as the canonical owner of destructive score-object operations. + +`openat`/`unlinkat` are used because their relative-path semantics bind resolution to an already-open directory descriptor, avoiding ancestor-path substitution. `O_NOFOLLOW` prevents the final opened entry from being followed as a symbolic link. These controls reduce the pathname race class that existed in `fs::remove_file(path)`. + +The selected repair does **not** claim that POSIX `unlinkat` atomically binds the final directory entry to the previously observed inode. A narrow final `identity recheck -> unlinkat` basename-replacement interval remains and is still tracked as residual risk. Eliminating that final-entry race requires a separately justified portable or platform-specific primitive; this repair does not fabricate such a guarantee. + +## Security and product effect + +Untrusted state remains every pre-existing Score Storage directory entry. Cleanup errors remain generic and do not expose buyer paths or PDF bytes. An identity mismatch, symlink/non-regular entry, parent-open failure, reopen failure, or unlink failure is fail-closed. + +The repair is buyer-visible only through safer failure semantics: BandScope must prefer leaving an owned temporary artifact for later recovery over deleting bytes through stale pathname authority. It does not alter Project Persistence metadata, attachment intent, recovery UI, or Score Storage receipt semantics. + +## Test and release claim boundary + +The source/test/workflow lineage above is current implementation evidence only. Exact-current-head owner-native and repository/security/review gates must be read fresh after the final descendant head settles. Predecessor `800d775b1c84c569fbbdf4ec184c3ae52527c275` owner-native GREEN is not transferable. + +Still open in this bounded context: the final Unix basename micro-race, deliberate cancellation/disk-full/permission/power-loss fault evidence, Windows directory-entry successful-return durability, old unleased-build coexistence, protected/released integration, and cross-owner recovery UX. + +## References + +The Open Group. (2024). *open, openat — open file relative to a directory file descriptor*. POSIX.1-2024. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + +The Open Group. (2018). *unlink, unlinkat — remove a directory entry*. POSIX.1-2017. https://pubs.opengroup.org/onlinepubs/9699919799/functions/unlink.html From cd9b10570fe628720392068952991380b66266e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 22:33:58 +0900 Subject: [PATCH 103/166] ci(score): own Unix cleanup traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 25ec34b36..990a40275 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -23,6 +23,7 @@ on: - "docs/traceability/score-attachment-recovery-object-receipt.md" - "docs/traceability/score-attachment-post-link-recovery.md" - "docs/traceability/score-attachment-success-durability.md" + - "docs/traceability/score-attachment-unix-stage-cleanup.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -46,6 +47,7 @@ on: - "docs/traceability/score-attachment-recovery-object-receipt.md" - "docs/traceability/score-attachment-post-link-recovery.md" - "docs/traceability/score-attachment-success-durability.md" + - "docs/traceability/score-attachment-unix-stage-cleanup.md" - ".github/workflows/score-storage-native.yml" permissions: From aa3b05ff110503eec4f98349f917593c6f28705a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:03:35 +0900 Subject: [PATCH 104/166] test(score): require stage identity continuity during recovery --- .../core/tests/score_pdf_recovery_inventory.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_recovery_inventory.rs b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs index a9590ca66..ffce822f6 100644 --- a/apps/desktop/core/tests/score_pdf_recovery_inventory.rs +++ b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs @@ -98,3 +98,21 @@ fn inventory_preserves_stage_plus_destination_ambiguity() { assert!(destination.exists()); fs::remove_dir_all(root).expect("ambiguous fixture should be removable"); } + +#[test] +fn recovery_binds_admitted_stage_identity_to_cleanup() { + let source = include_str!("../src/score_recovery.rs"); + + assert!( + source.contains("admit_score_stage_for_cleanup(&stage)"), + "recovery must capture the admitted stage object before later validation and cleanup" + ); + assert!( + source.contains("remove_admitted_score_stage(&stage, admitted_stage)"), + "recovery cleanup must remain bound to the stage object admitted earlier in the transaction" + ); + assert!( + !source.contains("remove_score_pdf_attachment(&stage)"), + "generic pathname-time deletion may recapture a replacement object and cannot be recovery authority" + ); +} From a0e4f6285c2355c09800b09343f2b7bcab96d460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:06:09 +0900 Subject: [PATCH 105/166] fix(score): bind recovery cleanup to admitted stage content --- apps/desktop/core/src/score_recovery.rs | 134 +++++++++++++++++++++--- 1 file changed, 122 insertions(+), 12 deletions(-) diff --git a/apps/desktop/core/src/score_recovery.rs b/apps/desktop/core/src/score_recovery.rs index 09f332768..5e7239ad2 100644 --- a/apps/desktop/core/src/score_recovery.rs +++ b/apps/desktop/core/src/score_recovery.rs @@ -1,10 +1,10 @@ use crate::{ is_valid_score_id, read_validated_score_pdf, resolve_existing_score_pdf, score_publication, - score_storage, score_storage::remove_score_pdf_attachment, sha256_hex_reader, + score_storage, sha256_hex_reader, MAX_SCORE_PDF_BYTES, }; use std::{ fs::{self, File, OpenOptions}, - io::Cursor, + io::{Cursor, Read}, path::{Path, PathBuf}, }; @@ -48,6 +48,11 @@ struct ScoreWorkspaceLease { root: PathBuf, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct AdmittedScoreStage { + content_sha256: String, +} + #[cfg(unix)] const LOCK_EX: i32 = 2; #[cfg(unix)] @@ -163,6 +168,77 @@ fn validated_pdf_content_sha256(path: &Path) -> Result { sha256_hex_reader(Cursor::new(bytes)).map_err(|_| SCORE_RECOVERY_ERROR.to_string()) } +#[cfg(unix)] +fn open_stage_for_admission(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + + OpenOptions::new() + .read(true) + .custom_flags(O_NOFOLLOW) + .open(path) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string()) +} + +#[cfg(windows)] +fn open_stage_for_admission(path: &Path) -> Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + let mut options = OpenOptions::new(); + options + .read(true) + .share_mode(0x0000_0001 | 0x0000_0002 | 0x0000_0004) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + let file = options + .open(path) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + let metadata = file + .metadata() + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + Ok(file) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_stage_for_admission(_path: &Path) -> Result { + Err(SCORE_RECOVERY_ERROR.to_string()) +} + +fn admit_score_stage_for_cleanup(stage: &Path) -> Result { + let mut file = open_stage_for_admission(stage)?; + let metadata = file + .metadata() + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if !metadata.is_file() || metadata.len() > MAX_SCORE_PDF_BYTES { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + let expected_len = metadata.len(); + let mut bounded = (&mut file).take(expected_len.saturating_add(1)); + let content_sha256 = + sha256_hex_reader(&mut bounded).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + drop(bounded); + let after = file + .metadata() + .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + if after.len() != expected_len { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + Ok(AdmittedScoreStage { content_sha256 }) +} + +fn remove_admitted_score_stage( + stage: &Path, + admitted_stage: &AdmittedScoreStage, +) -> Result<(), String> { + let current = admit_score_stage_for_cleanup(stage)?; + if current != *admitted_stage { + return Err(SCORE_RECOVERY_ERROR.to_string()); + } + score_storage::remove_score_pdf_attachment(stage) + .map_err(|_| SCORE_RECOVERY_ERROR.to_string()) +} + fn recover_abandoned_score_stages( scores_root: &Path, lease: &ScoreWorkspaceLease, @@ -195,6 +271,7 @@ fn recover_abandoned_score_stages( return Err(SCORE_RECOVERY_ERROR.to_string()); } } + let admitted_stage = admit_score_stage_for_cleanup(&stage)?; let destination = scores_root.join(format!("{score_id}.pdf")); match fs::symlink_metadata(&destination) { @@ -213,8 +290,7 @@ fn recover_abandoned_score_stages( if stage_sha256 != destination_sha256 { return Err(SCORE_RECOVERY_ERROR.to_string()); } - remove_score_pdf_attachment(&stage) - .map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + remove_admitted_score_stage(&stage, &admitted_stage)?; removed += 1; continue; } @@ -222,7 +298,7 @@ fn recover_abandoned_score_stages( Err(_) => return Err(SCORE_RECOVERY_ERROR.to_string()), } - remove_score_pdf_attachment(&stage).map_err(|_| SCORE_RECOVERY_ERROR.to_string())?; + remove_admitted_score_stage(&stage, &admitted_stage)?; removed += 1; } Ok(removed) @@ -398,13 +474,18 @@ where /// owned staging namespace. Reserved symlink/reparse/non-regular entries, lock /// acquisition failure, unreadable directory state, a stage plus a /// content-different destination, or a supported-platform metadata barrier -/// failure fail closed. Errors contain neither absolute paths nor PDF bytes. A -/// barrier failure after publication does not guess-delete the object; later -/// inventory/recovery determines actual storage truth. Windows retains the -/// staged-file sync contract but does not yet claim directory-entry power-loss -/// durability. This recovery covers current-contract interruption states and -/// does not claim compatibility with an older concurrently running BandScope -/// build that never acquired the lease. +/// failure fail closed. Recovery binds a reserved stage to a bounded SHA-256 +/// content receipt before later validation and checks that receipt again before +/// lower-level identity-safe deletion, so a different stage substituted during +/// the transaction is preserved rather than recaptured as cleanup authority. +/// The lower Unix deletion primitive still retains its documented narrow final +/// identity-check-to-`unlinkat` race. Errors contain neither absolute paths nor +/// PDF bytes. A barrier failure after publication does not guess-delete the +/// object; later inventory/recovery determines actual storage truth. Windows +/// retains the staged-file sync contract but does not yet claim directory-entry +/// power-loss durability. This recovery covers current-contract interruption +/// states and does not claim compatibility with an older concurrently running +/// BandScope build that never acquired the lease. pub fn publish_score_pdf_attachment( source: &Path, scores_root: &Path, @@ -461,6 +542,35 @@ mod tests { assert_eq!(published_score_id("6fa459ea-ee8a-4ca4-894e-db77e160355e.PDF"), None); } + #[test] + fn admitted_stage_cleanup_rejects_different_content_replacement() { + let root = unique_test_dir("stage-admission-replacement"); + fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let moved = root.join("admitted-stage-before-replacement"); + fs::write(&stage, b"%PDF-1.7\nadmitted").expect("admitted stage should be written"); + let admitted = + admit_score_stage_for_cleanup(&stage).expect("stage admission should succeed"); + + fs::rename(&stage, &moved).expect("admitted stage should move before cleanup"); + fs::write(&stage, b"%PDF-1.7\nreplacement") + .expect("different-content replacement should be written"); + + let error = remove_admitted_score_stage(&stage, &admitted) + .expect_err("recovery must not recapture a different stage object as cleanup authority"); + + assert_eq!(error, SCORE_RECOVERY_ERROR); + assert_eq!( + fs::read(&stage).expect("replacement should survive failed cleanup"), + b"%PDF-1.7\nreplacement" + ); + assert_eq!( + fs::read(&moved).expect("admitted stage should remain preserved"), + b"%PDF-1.7\nadmitted" + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn publication_metadata_barrier_runs_while_workspace_lease_is_held() { let root = unique_test_dir("publication-barrier-lease"); From c2634de780a1ddf79c0d2b6fbda90898cd05daa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:06:43 +0900 Subject: [PATCH 106/166] test(score): match recovery cleanup call signature --- apps/desktop/core/tests/score_pdf_recovery_inventory.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/tests/score_pdf_recovery_inventory.rs b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs index ffce822f6..837b1a739 100644 --- a/apps/desktop/core/tests/score_pdf_recovery_inventory.rs +++ b/apps/desktop/core/tests/score_pdf_recovery_inventory.rs @@ -108,7 +108,7 @@ fn recovery_binds_admitted_stage_identity_to_cleanup() { "recovery must capture the admitted stage object before later validation and cleanup" ); assert!( - source.contains("remove_admitted_score_stage(&stage, admitted_stage)"), + source.contains("remove_admitted_score_stage(&stage, &admitted_stage)"), "recovery cleanup must remain bound to the stage object admitted earlier in the transaction" ); assert!( From 41c43d4a8049f3e679c6f5c7ab575d44ae0eb364 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 23:07:10 +0900 Subject: [PATCH 107/166] docs(score): trace admitted-stage cleanup continuity --- .../score-attachment-recovery-inventory.md | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/traceability/score-attachment-recovery-inventory.md b/docs/traceability/score-attachment-recovery-inventory.md index 413e22f41..653991379 100644 --- a/docs/traceability/score-attachment-recovery-inventory.md +++ b/docs/traceability/score-attachment-recovery-inventory.md @@ -24,15 +24,18 @@ The id-only inventory was later found insufficient as destructive mutation autho Post-link source RED `1ce4bb86864ef845d39f7cb80614a2b686213499` then established a process-death state where synchronized `.score-.stage` and hard-linked `.pdf` both survive. Causal repair `48a7ccfab996330fa415e68afaabc34782d62ad4` makes equal validated stage/destination bytes recoverable by retiring only the temporary stage alias while preserving the destination as a published recovery candidate. Content-different or indeterminate pairs still preserve both and fail closed. See `score-attachment-post-link-recovery.md`. +Fresh review then found an admission-to-cleanup discontinuity: recovery inspected a reserved stage, performed later validation, and finally called the generic score remover, which captured deletion authority only at deletion time. An old unleased writer or same-user filesystem mutation could therefore substitute a different stage during that longer interval and have the replacement recaptured as cleanup authority. Source RED `aa3b05ff110503eec4f98349f917593c6f28705a` added an owned recovery contract that rejects the generic stage deletion path. Causal repair `a0e4f6285c2355c09800b09343f2b7bcab96d460` admits the bounded stage through a no-follow native open, binds that admitted content to SHA-256, and requires the same bounded content immediately before lower identity-safe deletion. `c2634de780a1ddf79c0d2b6fbda90898cd05daa0` corrected the source-contract assertion to the actual borrowed call signature. The superseded test-only head did not receive a terminal hosted verdict, so this is source-level RED only. + ## Inventory contract Before returning published-object truth, the owner: - validates and acquires the cross-process Score Storage workspace lease; - runs abandoned-stage recovery while that lease is held; -- removes a stage-only orphan through the existing object-deletion boundary; -- for stage plus destination, validates both bounded PDF byte streams and retires only the stage alias when their shared-kernel SHA-256 identities are equal; -- preserves both and fails closed when stage/destination bytes differ or validation is indeterminate; +- admits each reserved stage through a native no-follow open and captures a bounded SHA-256 content receipt before later validation or cleanup; +- removes a stage-only orphan only when the current bounded stage content still matches that admitted receipt; +- for stage plus destination, validates both bounded PDF byte streams and retires only the stage alias when their shared-kernel SHA-256 identities are equal and the stage still matches its admitted receipt; +- preserves evidence and fails closed when admitted/current stage content changes, stage/destination bytes differ, or validation is indeterminate; - enumerates only exact `.pdf` names; - resolves every matching owned name through the existing native containment/symlink guard; - ignores unrelated filenames instead of broadening Score Storage ownership; @@ -41,7 +44,7 @@ Before returning published-object truth, the owner: `inventory_published_score_pdf_ids` remains discovery-only compatibility surface. `inventory_published_score_pdf_receipts` is the mutation-freshness observation surface: each receipt binds the validated logical id to the SHA-256 of the current bounded PDF bytes while the lease is held. The digest is equality/content identity only, not authenticity, provenance or durable project acceptance. -The lease makes inventory stable with respect to another writer using the current contract. It does not make an older build that never takes the lease compatible. +The lease makes inventory stable with respect to another writer using the current contract. It does not make an older build that never takes the lease compatible. The admitted-stage receipt narrows that old-build/replacement exposure by preventing a different bounded stage from being recaptured over the full recovery transaction, but it does not turn the remaining Unix identity-check-to-`unlinkat` interval into an object-handle deletion primitive. ## Mutation freshness @@ -55,26 +58,28 @@ Scanning the scores directory from React is rejected because it exports filesyst Automatic attachment or deletion of unreferenced ids is rejected because byte existence cannot reveal durable project intent. Id-only destructive authority is rejected because same-id ABA can apply stale intent to replacement bytes. Blind deletion of either side of stage-plus-destination is rejected because one side may be the only durable buyer copy; permanent rejection of every equal-content post-link state is also rejected because a valid current-contract crash would wedge the workspace indefinitely. -A mutable sidecar lifecycle ledger is not introduced in this slice. It would create a second persistence protocol whose crash semantics would themselves need reconciliation with the project document. +Treating the reserved stage pathname itself as durable recovery authority is rejected because pathname lookup can observe a replacement object after admission. Content binding is used here because the recovery action is retiring a temporary alias, not proving provenance: a same-content replacement remains equivalent for buyer-byte preservation, while a different-content replacement fails closed. A mutable sidecar lifecycle ledger is not introduced in this slice because it would create a second persistence protocol whose crash semantics would themselves need reconciliation with the project document. ## Security Notes **Untrusted input.** Every directory entry under the score workspace is untrusted. Only exact reserved stage names and exact valid score-id PDF names enter the owned recovery/inventory namespace. -**Trust boundary.** App-owned score workspace → OS-held Score Storage lease → abandoned/publication recovery → exact owned-name parsing → existing native containment resolver and bounded PDF validator → id inventory or content receipt. +**Trust boundary.** App-owned score workspace → OS-held Score Storage lease → native no-follow stage admission → bounded content identity → abandoned/publication recovery → exact owned-name parsing → existing native containment resolver and bounded PDF validator → id inventory or content receipt. -**Safe failure.** Lease contention, unreadable directory state, suspicious matching objects, unsafe resolution, content-different stage/destination state and stale receipt mismatch do not fall back to destructive guessing. Equal-content post-link recovery removes only the temporary stage alias and preserves published bytes. +**Safe failure.** Lease contention, unreadable directory state, suspicious matching objects, admitted/current stage mismatch, unsafe resolution, content-different stage/destination state and stale receipt mismatch do not fall back to destructive guessing. Equal-content post-link recovery removes only the temporary stage alias and preserves published bytes. **Privacy.** The APIs expose BandScope-generated score ids and, for receipts, lowercase SHA-256 content identity. They do not return local paths, original selected filenames or PDF bytes. ## Test points -`score_pdf_recovery_inventory` covers fresh-process rediscovery of production-published objects, deterministic ordering, unrelated-file exclusion and stage recovery before listing. `score_pdf_interruption_recovery` covers stage-only process termination and post-link process termination. `score_pdf_recovery_object_receipt` covers same-id remove/republish ABA and fresh receipt-bound deletion. Existing publication/retention/restart/wiring regressions remain in the same owner workflow. +`score_pdf_recovery_inventory` covers fresh-process rediscovery of production-published objects, deterministic ordering, unrelated-file exclusion, stage recovery before listing, and the source-level requirement that recovery uses admitted-stage cleanup rather than generic pathname-time deletion. The owner unit suite additionally replaces an admitted stage with different content and requires both the replacement and the originally admitted bytes to survive the fail-closed result. `score_pdf_interruption_recovery` covers stage-only process termination and post-link process termination. `score_pdf_recovery_object_receipt` covers same-id remove/republish ABA and fresh receipt-bound deletion. Existing publication/retention/restart/wiring regressions remain in the same owner workflow. ## Claim boundary and next step -Score Storage now provides restart object discovery, content-bound mutation freshness and non-destructive recovery for current-contract stage-only and equal-content post-link process death. It still does **not** identify which objects are referenced by the durable project document, infer original display filenames, decide Recover / Preserve / Discard, or provide buyer-visible recovery UX. +Score Storage now provides restart object discovery, content-bound mutation freshness and non-destructive recovery for current-contract stage-only and equal-content post-link process death. It also binds recovery cleanup to the bounded stage content admitted earlier in the transaction, so a different replacement cannot be silently recaptured over the long recovery interval. This does **not** claim provenance, exact-object authenticity, or elimination of the already documented final Unix identity-check-to-`unlinkat` micro-race. + +Score Storage still does not identify which objects are referenced by the durable project document, infer original display filenames, decide Recover / Preserve / Discard, or provide buyer-visible recovery UX. Until #865 and #1241 integrate and #970 consumes a protected/released Score Storage contract, `PDF durable -> metadata not durable` remains an open commercial gap. The consumer flow must combine durable project attachment ids, active project identity and fresh Score Storage receipts, then re-read both owner truths immediately before a mutation. `missing_referenced_score_ids` remains a broken-reference condition rather than cleanup authority. -Still open at the Score Storage owner: old unleased-build compatibility, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback, the remaining Unix final basename race, packaged fault evidence and release/signing settlement. +Still open at the Score Storage owner: old unleased-build compatibility beyond the admitted-content guard, explicit cancellation, disk-full, permission failure, power-loss durability, complete project deletion/recovery rollback, the remaining Unix final basename race, packaged fault evidence and release/signing settlement. From a5e1abd4bfab4bf8d5c55b4efc26250c1d8481f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:05:08 +0900 Subject: [PATCH 108/166] test(score): add interrupted-stage fault recovery evidence --- .../core/tests/score_pdf_fault_recovery.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_fault_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_fault_recovery.rs b/apps/desktop/core/tests/score_pdf_fault_recovery.rs new file mode 100644 index 000000000..7c8ac12e6 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_fault_recovery.rs @@ -0,0 +1,55 @@ +use bandscope_desktop_core::inventory_published_score_pdf_receipts; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-score-fault-{name}-{suffix}")) +} + +#[test] +fn interrupted_partial_stage_is_retired_without_inventing_a_published_object() { + let root = unique_test_dir("partial-stage"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + + // A process can disappear while the fixed-buffer copy is still incomplete. + // Recovery owns the reserved temporary namespace, so an incomplete + // stage-only residue is cleanup state, not a published score candidate. + std::fs::write(&stage, b"%PD").expect("partial stage fixture should be written"); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("restart recovery should retire a partial stage-only residue"); + + assert!(receipts.is_empty(), "partial staging must not become buyer-visible object truth"); + assert!(!stage.exists(), "restart recovery should retire only the reserved partial stage"); + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn unreadable_reserved_stage_fails_closed_and_preserves_evidence() { + use std::os::unix::fs::PermissionsExt; + + let root = unique_test_dir("permission-denied-stage"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + std::fs::write(&stage, b"%PDF-1.7\npermission fault") + .expect("reserved stage fixture should be written"); + std::fs::set_permissions(&stage, std::fs::Permissions::from_mode(0o000)) + .expect("permission fault should be installed"); + + let result = inventory_published_score_pdf_receipts(&root); + + assert!(result.is_err(), "indeterminate stage content must fail closed"); + assert!(stage.exists(), "permission failure must preserve the reserved stage as evidence"); + + std::fs::set_permissions(&stage, std::fs::Permissions::from_mode(0o600)) + .expect("fixture permissions should be restored for cleanup"); + let _ = std::fs::remove_dir_all(root); +} From f7ed8d68b90cc16208af6a122d77b4ad6cec5ec8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:05:20 +0900 Subject: [PATCH 109/166] test(score): require owner execution of fault recovery evidence --- apps/desktop/core/tests/score_pdf_success_durability.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_success_durability.rs b/apps/desktop/core/tests/score_pdf_success_durability.rs index 45a220f42..0d13d8044 100644 --- a/apps/desktop/core/tests/score_pdf_success_durability.rs +++ b/apps/desktop/core/tests/score_pdf_success_durability.rs @@ -1,5 +1,6 @@ const ROOT_SOURCE: &str = include_str!("../src/root.rs"); const RECOVERY_SOURCE: &str = include_str!("../src/score_recovery.rs"); +const OWNER_WORKFLOW: &str = include_str!("../../../../.github/workflows/score-storage-native.yml"); #[test] fn public_score_publication_routes_through_lease_bound_success_durability_boundary() { @@ -31,3 +32,11 @@ fn public_score_publication_routes_through_lease_bound_success_durability_bounda "the public publication path must supply the platform durability barrier" ); } + +#[test] +fn owner_workflow_executes_interruption_and_permission_fault_recovery_contracts() { + assert!( + OWNER_WORKFLOW.contains("--test score_pdf_fault_recovery"), + "Score Storage CI must explicitly execute the interruption/permission fault recovery regression" + ); +} From 077512df4a65679ed6dea1098c201c6055cc0375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:05:35 +0900 Subject: [PATCH 110/166] ci(score): execute fault recovery regression --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 990a40275..9a71fef2a 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -89,7 +89,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, Unix cleanup, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -100,5 +100,6 @@ jobs: --test score_pdf_recovery_inventory --test score_pdf_recovery_object_receipt --test score_pdf_success_durability + --test score_pdf_fault_recovery --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring From 687f7020deacbc6300521fc7efabe85029b57cb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:06:04 +0900 Subject: [PATCH 111/166] docs(score): trace interruption and permission recovery evidence --- .../score-attachment-fault-recovery.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/traceability/score-attachment-fault-recovery.md diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md new file mode 100644 index 000000000..b5da4f1b4 --- /dev/null +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -0,0 +1,39 @@ +# Score attachment fault-recovery evidence + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment +Related owner: Project Persistence #970 + +## Problem + +Score Storage already had writer-death recovery for a synchronized stage and for the post-link/pre-retirement window, but its owner workflow did not contain an explicit regression for two earlier failure classes: an interrupted fixed-buffer copy that leaves an incomplete reserved stage, and a native permission failure that makes the reserved stage indeterminate at restart. + +The missing regression mattered because these two states require opposite actions. A stage-only residue from an interrupted copy is owned temporary state and may be retired without becoming a published score object. An unreadable reserved stage is not safely classifiable and must be preserved while recovery fails closed. + +## Repair lineage + +`a5e1abd4bfab4bf8d5c55b4efc26250c1d8481f7` added `score_pdf_fault_recovery.rs` with the buyer-byte safety cases. `f7ed8d68b90cc16208af6a122d77b4ad6cec5ec8` then made the already-owned `score_pdf_success_durability` contract require the Score Storage workflow to execute that regression explicitly. At that source head the workflow did not yet contain `--test score_pdf_fault_recovery`, so the existing owner contract had a deterministic source-level RED. No terminal hosted RED is claimed because the causal CI repair followed immediately. + +`077512df4a65679ed6dea1098c201c6055cc0375` added the fault-recovery regression to the explicit `score-storage-native` invocation on macOS 15 and Windows Server 2025. + +## Fault semantics + +The interruption fixture leaves only `%PD` in an exact reserved `.score-.stage` name. This represents a process disappearing before the descriptor-bounded copy completes. Restart inventory must retire that reserved temporary residue and return no published receipt. The test does not claim that process termination is equivalent to sudden power loss or disk-full; those require separate fault evidence. + +On Unix, the permission fixture makes an exact reserved stage unreadable before restart inventory. Recovery must return an error and preserve the stage. It must not guess that unreadable bytes are disposable merely because their pathname is in the reserved namespace. The fixture restores permissions only after the assertion so test cleanup does not broaden production behavior. + +Windows does not reuse the POSIX mode fixture. Windows permission/ACL fault evidence remains tied to the app-owned DACL model and requires a Windows-native ACL mutation fixture rather than a synthetic chmod analogue. + +## Security Notes + +**Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. + +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; actual cleanup still passes through Score Storage lease, no-follow/reparse checks, bounded admission content and native deletion authority. + +**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate/unreadable stage state is preserved and blocks recovery instead of falling back to pathname-only deletion. + +**Claim boundary.** This slice proves explicit owner regressions for incomplete-stage cancellation residue and Unix permission-denied recovery behavior. It does not close disk-full, Windows ACL failure, sudden power-loss, packaged executable or directory-entry durability acceptance. + +## Next buyer gap + +Continue fault injection at the real publication boundaries `stage copy -> stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`. The next useful acceptance is a deterministic disk-full/write-failure fixture that demonstrates partial-stage non-destruction/recovery without shrinking the production payload, followed by Windows-native ACL denial and power-loss/durability evidence on packaged builds. From d0410479c9002ca924256db95d6003dd17bed58b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:06:25 +0900 Subject: [PATCH 112/166] ci(score): track fault recovery traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 9a71fef2a..ca7a1136b 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -24,6 +24,7 @@ on: - "docs/traceability/score-attachment-post-link-recovery.md" - "docs/traceability/score-attachment-success-durability.md" - "docs/traceability/score-attachment-unix-stage-cleanup.md" + - "docs/traceability/score-attachment-fault-recovery.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -48,6 +49,7 @@ on: - "docs/traceability/score-attachment-post-link-recovery.md" - "docs/traceability/score-attachment-success-durability.md" - "docs/traceability/score-attachment-unix-stage-cleanup.md" + - "docs/traceability/score-attachment-fault-recovery.md" - ".github/workflows/score-storage-native.yml" permissions: From 3bee635639cf0976b8628350a0be62424676350a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:30:02 +0900 Subject: [PATCH 113/166] test(score): cover Windows ACL-denied recovery --- .../tests/score_pdf_windows_acl_recovery.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_windows_acl_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_windows_acl_recovery.rs b/apps/desktop/core/tests/score_pdf_windows_acl_recovery.rs new file mode 100644 index 000000000..4847eebe6 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_windows_acl_recovery.rs @@ -0,0 +1,70 @@ +#![cfg(windows)] + +use bandscope_desktop_core::inventory_published_score_pdf_receipts; +use std::{ + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-score-windows-acl-{name}-{suffix}")) +} + +fn current_windows_identity() -> String { + let output = Command::new("whoami") + .output() + .expect("whoami should be available on the supported Windows runner"); + assert!(output.status.success(), "whoami should resolve the current Windows identity"); + String::from_utf8(output.stdout) + .expect("whoami output should be UTF-8 on the hosted runner") + .trim() + .to_owned() +} + +fn run_icacls(path: &Path, args: &[String]) { + let mut command = Command::new("icacls"); + command.arg(path); + command.args(args); + let output = command + .output() + .expect("icacls should be available on the supported Windows runner"); + assert!( + output.status.success(), + "icacls failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn windows_acl_denied_reserved_stage_fails_closed_and_preserves_evidence() { + let root = unique_test_dir("reserved-stage"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + std::fs::write(&stage, b"%PDF-1.7\r\nwindows acl fault") + .expect("reserved stage fixture should be written"); + + let identity = current_windows_identity(); + run_icacls(&stage, &["/inheritance:r".to_string()]); + run_icacls(&stage, &["/deny".to_string(), format!("{identity}:(R)")]); + + let result = inventory_published_score_pdf_receipts(&root); + + // Restore the ACL before inspecting or deleting the fixture. The assertion + // below therefore proves recovery did not remove the reserved stage while + // read authority was indeterminate, rather than relying on Path::exists() + // through the deliberately denied ACL. + run_icacls(&stage, &["/reset".to_string()]); + + assert!(result.is_err(), "Windows ACL denial must make recovery fail closed"); + assert!(stage.exists(), "ACL-denied recovery must preserve the reserved stage as evidence"); + + let _ = std::fs::remove_dir_all(root); +} From 78b775b5241180a6077aea6e2f34f35707e3b551 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:30:22 +0900 Subject: [PATCH 114/166] ci(score): own Windows ACL recovery regression --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index ca7a1136b..5741aee7e 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -91,7 +91,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, Unix cleanup, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, Windows ACL, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -103,5 +103,6 @@ jobs: --test score_pdf_recovery_object_receipt --test score_pdf_success_durability --test score_pdf_fault_recovery + --test score_pdf_windows_acl_recovery --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring From abadc9d53f25135c4c688513b1f4a9ea33bd9203 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:30:42 +0900 Subject: [PATCH 115/166] docs(score): trace Windows ACL recovery evidence --- .../score-attachment-windows-acl-recovery.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/traceability/score-attachment-windows-acl-recovery.md diff --git a/docs/traceability/score-attachment-windows-acl-recovery.md b/docs/traceability/score-attachment-windows-acl-recovery.md new file mode 100644 index 000000000..62bd63223 --- /dev/null +++ b/docs/traceability/score-attachment-windows-acl-recovery.md @@ -0,0 +1,31 @@ +# Score attachment Windows ACL recovery + +## Problem + +Score Storage restart recovery already fails closed when a reserved staging object cannot be read, but the owned native suite previously exercised that permission boundary only with Unix mode bits. On the supported Windows Server 2025 lane there was no production-path evidence that an ACL-denied reserved stage is preserved rather than deleted or promoted into published object truth. + +This matters because the Windows implementation deliberately relies on the app-owned Score Storage parent DACL rather than Unix-style `0600` mode bits. A recovery contract that is only proven on Unix does not establish the corresponding Windows confidentiality and evidence-preservation behavior. + +## Constraint and ownership + +BandScope Score Storage owns the reserved `.score-.stage` namespace and restart cleanup semantics. Project Persistence does not inspect or mutate Score Storage paths. The test must therefore drive the public restart inventory boundary and must not duplicate the private Windows handle/deletion implementation. + +The fixture uses the supported Windows runner's native `icacls` command to remove inherited access and install an explicit read deny ACE for the current process identity. Recovery then calls `inventory_published_score_pdf_receipts()` exactly as a fresh process would. The ACL is reset before the fixture is inspected or deleted so the preservation assertion does not depend on querying the intentionally denied path. + +## RED and repair + +Commit `3bee635639cf0976b8628350a0be62424676350a` added `apps/desktop/core/tests/score_pdf_windows_acl_recovery.rs`. At that source head the owner workflow matched the file through `score_pdf_*.rs` but its explicit `cargo test` invocation did not execute the new integration test. That was a deterministic source-level owner-evidence RED; no hosted failing verdict is claimed for the superseded test-only head. + +Commit `78b775b5241180a6077aea6e2f34f35707e3b551` repaired CI ownership by adding `--test score_pdf_windows_acl_recovery` to the exact macOS 15 / Windows Server 2025 Score Storage invocation. The macOS lane compiles the crate-level `#![cfg(windows)]` test as zero platform tests; the Windows lane owns the behavioral ACL denial assertion. + +## Acceptance + +The Windows native lane must prove all of the following on the exact PR head: + +- a valid reserved stage exists before the fault is installed; +- the current Windows identity receives an explicit read-deny ACL on that stage; +- restart inventory returns an error rather than inventing a published receipt; +- after ACL reset, the same reserved stage still exists and can be cleaned by the test harness; +- no claim is made about Windows directory-entry power-loss durability, disk-full behavior, packaged cancellation, or Project Persistence attachment recovery. + +The result is evidence for fail-closed Windows ACL recovery only. It does not weaken the existing requirement for fresh receipt-bound mutation authority, native object identity checks, or the separate successful-return durability decision. From 117ebf868b6520e91c9f8a84f23dcef5f2228308 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:30:58 +0900 Subject: [PATCH 116/166] ci(score): track Windows ACL recovery traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 5741aee7e..dbc456390 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -25,6 +25,7 @@ on: - "docs/traceability/score-attachment-success-durability.md" - "docs/traceability/score-attachment-unix-stage-cleanup.md" - "docs/traceability/score-attachment-fault-recovery.md" + - "docs/traceability/score-attachment-windows-acl-recovery.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -50,6 +51,7 @@ on: - "docs/traceability/score-attachment-success-durability.md" - "docs/traceability/score-attachment-unix-stage-cleanup.md" - "docs/traceability/score-attachment-fault-recovery.md" + - "docs/traceability/score-attachment-windows-acl-recovery.md" - ".github/workflows/score-storage-native.yml" permissions: From 22779b70c54109689aebcc22edde450778d2cc7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 00:59:48 +0900 Subject: [PATCH 117/166] test(score): add kernel write-failure recovery evidence --- .../tests/score_pdf_write_failure_recovery.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_write_failure_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_write_failure_recovery.rs b/apps/desktop/core/tests/score_pdf_write_failure_recovery.rs new file mode 100644 index 000000000..85c448d06 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_write_failure_recovery.rs @@ -0,0 +1,112 @@ +#[cfg(unix)] +use bandscope_desktop_core::{ + inventory_published_score_pdf_receipts, publish_score_pdf_attachment, +}; +#[cfg(unix)] +use std::{ + env, fs, + path::PathBuf, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(unix)] +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +#[cfg(unix)] +const WRITE_FAILURE_CHILD_ENV: &str = "BANDSCOPE_SCORE_WRITE_FAILURE_CHILD"; +#[cfg(unix)] +const WRITE_FAILURE_ROOT_ENV: &str = "BANDSCOPE_SCORE_WRITE_FAILURE_ROOT"; +#[cfg(unix)] +const WRITE_FAILURE_SOURCE_ENV: &str = "BANDSCOPE_SCORE_WRITE_FAILURE_SOURCE"; +#[cfg(unix)] +const SOURCE_BYTES: usize = 128 * 1024; + +#[cfg(unix)] +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + env::temp_dir().join(format!("bandscope-score-write-failure-{name}-{suffix}")) +} + +#[cfg(unix)] +fn score_fixture() -> Vec { + let mut bytes = vec![b'x'; SOURCE_BYTES]; + bytes[..9].copy_from_slice(b"%PDF-1.7\n"); + bytes +} + +#[cfg(unix)] +#[test] +fn kernel_file_size_limit_child() { + if env::var_os(WRITE_FAILURE_CHILD_ENV).is_none() { + return; + } + + let root = PathBuf::from( + env::var_os(WRITE_FAILURE_ROOT_ENV).expect("write-failure child root should be supplied"), + ); + let source = PathBuf::from( + env::var_os(WRITE_FAILURE_SOURCE_ENV) + .expect("write-failure child source should be supplied"), + ); + + let error = publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect_err("the kernel-enforced file-size limit must make stage publication fail"); + + assert_eq!(error, "Could not attach the score PDF."); +} + +#[cfg(unix)] +#[test] +fn kernel_write_failure_does_not_publish_or_leave_stage_authority() { + let root = unique_test_dir("rlimit-fsize"); + fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + fs::write(&source, score_fixture()).expect("full-size write-failure source should be written"); + + let current_test_binary = env::current_exe().expect("current test binary should resolve"); + let status = Command::new("sh") + .arg("-c") + .arg("ulimit -f 1; trap '' 25; exec \"$1\" --exact kernel_file_size_limit_child --nocapture") + .arg("bandscope-score-write-failure") + .arg(¤t_test_binary) + .env(WRITE_FAILURE_CHILD_ENV, "1") + .env(WRITE_FAILURE_ROOT_ENV, &root) + .env(WRITE_FAILURE_SOURCE_ENV, &source) + .status() + .expect("write-failure child process should launch"); + + assert!( + status.success(), + "child must observe a handled write failure instead of crashing or reporting success" + ); + + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + assert!( + !destination.exists(), + "a kernel write failure before stage sync must not create published score truth" + ); + assert!( + !stage.exists(), + "the failed writer must retire only its owned partial stage before returning" + ); + assert_eq!( + fs::metadata(&source) + .expect("source should remain intact after failed publication") + .len(), + SOURCE_BYTES as u64, + "fault injection must not shrink the product source or production size ceiling" + ); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("fresh restart inventory should accept the cleaned workspace"); + assert!( + receipts.is_empty(), + "write failure must not become a published recovery candidate" + ); + + let _ = fs::remove_dir_all(root); +} From c1ff55f277899d8a98015a2f6b6063308bb1017d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:00:07 +0900 Subject: [PATCH 118/166] ci(score): execute kernel write-failure recovery regression --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index dbc456390..a374a5d2d 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -93,7 +93,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, Windows ACL, Unix cleanup, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, write-failure, Windows ACL, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -105,6 +105,7 @@ jobs: --test score_pdf_recovery_object_receipt --test score_pdf_success_durability --test score_pdf_fault_recovery + --test score_pdf_write_failure_recovery --test score_pdf_windows_acl_recovery --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring From 9251cfada52462271807d78f22f08de77cc3bb33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:00:37 +0900 Subject: [PATCH 119/166] docs(score): trace kernel write-failure recovery contract --- .../score-attachment-fault-recovery.md | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index b5da4f1b4..f0b58d064 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -6,34 +6,40 @@ Related owner: Project Persistence #970 ## Problem -Score Storage already had writer-death recovery for a synchronized stage and for the post-link/pre-retirement window, but its owner workflow did not contain an explicit regression for two earlier failure classes: an interrupted fixed-buffer copy that leaves an incomplete reserved stage, and a native permission failure that makes the reserved stage indeterminate at restart. +Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults that occur before publication as well as faults that leave an indeterminate reserved stage. -The missing regression mattered because these two states require opposite actions. A stage-only residue from an interrupted copy is owned temporary state and may be retired without becoming a published score object. An unreadable reserved stage is not safely classifiable and must be preserved while recovery fails closed. +Three cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. These states must never invent a published score object, and an indeterminate stage must not be deleted on pathname authority alone. ## Repair lineage -`a5e1abd4bfab4bf8d5c55b4efc26250c1d8481f7` added `score_pdf_fault_recovery.rs` with the buyer-byte safety cases. `f7ed8d68b90cc16208af6a122d77b4ad6cec5ec8` then made the already-owned `score_pdf_success_durability` contract require the Score Storage workflow to execute that regression explicitly. At that source head the workflow did not yet contain `--test score_pdf_fault_recovery`, so the existing owner contract had a deterministic source-level RED. No terminal hosted RED is claimed because the causal CI repair followed immediately. +`a5e1abd4bfab4bf8d5c55b4efc26250c1d8481f7` added `score_pdf_fault_recovery.rs` with interrupted-copy and Unix permission-denial cases. `f7ed8d68b90cc16208af6a122d77b4ad6cec5ec8` then made the existing `score_pdf_success_durability` contract require explicit owner-workflow execution. At that source head the workflow omitted `--test score_pdf_fault_recovery`, so the owner contract had a deterministic source-level RED. No terminal hosted RED is claimed because `077512df4a65679ed6dea1098c201c6055cc0375` repaired the explicit invocation before a failing hosted verdict settled. -`077512df4a65679ed6dea1098c201c6055cc0375` added the fault-recovery regression to the explicit `score-storage-native` invocation on macOS 15 and Windows Server 2025. +Windows ACL denial is covered separately by `score-attachment-windows-acl-recovery.md` and its Windows Server 2025 regression. + +`22779b70c54109689aebcc22edde450778d2cc7e` adds `score_pdf_write_failure_recovery.rs`. The test drives the public `publish_score_pdf_attachment` path in a child process whose kernel file-size limit is reduced with POSIX `ulimit -f`. The source PDF is created before the limit is installed and remains 128 KiB; no production size constant or publication ceiling is changed. The child ignores `SIGXFSZ` so the write returns through the normal Rust I/O error path instead of terminating the process. At that test-only head the `score_pdf_*.rs` path filter triggered the owner workflow, but the explicit `cargo test` list did not execute the new regression. That is a source-level owner-evidence RED, not a hosted behavioral RED. + +`c1ff55f277899d8a98015a2f6b6063308bb1017d` repairs the owner workflow by adding `--test score_pdf_write_failure_recovery` to the macOS 15 / Windows Server 2025 invocation. The behavioral assertion is Unix-only; Windows compiles the integration-test crate with zero platform tests for this case. ## Fault semantics -The interruption fixture leaves only `%PD` in an exact reserved `.score-.stage` name. This represents a process disappearing before the descriptor-bounded copy completes. Restart inventory must retire that reserved temporary residue and return no published receipt. The test does not claim that process termination is equivalent to sudden power loss or disk-full; those require separate fault evidence. +The interrupted-copy fixture leaves only `%PD` in an exact reserved `.score-.stage` name. This represents a process disappearing before the descriptor-bounded copy completes. Restart inventory retires that owned temporary residue and returns no published receipt. + +On Unix, the permission fixture makes an exact reserved stage unreadable before restart inventory. Recovery returns an error and preserves the stage. It does not infer that unreadable bytes are disposable merely because their pathname is in the reserved namespace. -On Unix, the permission fixture makes an exact reserved stage unreadable before restart inventory. Recovery must return an error and preserve the stage. It must not guess that unreadable bytes are disposable merely because their pathname is in the reserved namespace. The fixture restores permissions only after the assertion so test cleanup does not broaden production behavior. +The kernel write-failure regression exercises a different boundary. The parent creates a 128 KiB PDF-shaped source, then starts the current integration-test binary in a child process with a 512-byte file-size limit. The production publisher opens the real private stage and encounters the kernel write failure while copying. The child must return the generic attachment error rather than crash or report success. After the child exits, the parent verifies that no `.pdf` exists, the writer-owned partial stage was retired, the source length is unchanged, and fresh restart inventory is empty. -Windows does not reuse the POSIX mode fixture. Windows permission/ACL fault evidence remains tied to the app-owned DACL model and requires a Windows-native ACL mutation fixture rather than a synthetic chmod analogue. +This is intentionally not described as an ENOSPC or physical-disk-full test. `RLIMIT_FSIZE`/`ulimit -f` produces a kernel-enforced write failure without requiring privileged filesystem provisioning and without changing BandScope's 25 MiB product boundary. Actual capacity exhaustion remains separate acceptance evidence. ## Security Notes **Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. -**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; actual cleanup still passes through Score Storage lease, no-follow/reparse checks, bounded admission content and native deletion authority. +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. -**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate/unreadable stage state is preserved and blocks recovery instead of falling back to pathname-only deletion. +**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. -**Claim boundary.** This slice proves explicit owner regressions for incomplete-stage cancellation residue and Unix permission-denied recovery behavior. It does not close disk-full, Windows ACL failure, sudden power-loss, packaged executable or directory-entry durability acceptance. +**Claim boundary.** Current owner evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, and a Unix kernel-enforced write failure on the real publication path. It does not prove ENOSPC/capacity exhaustion, packaged cancellation, sudden power loss, or Windows directory-entry successful-return durability. ## Next buyer gap -Continue fault injection at the real publication boundaries `stage copy -> stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`. The next useful acceptance is a deterministic disk-full/write-failure fixture that demonstrates partial-stage non-destruction/recovery without shrinking the production payload, followed by Windows-native ACL denial and power-loss/durability evidence on packaged builds. +The next storage fault evidence should target actual capacity exhaustion or another OS-native ENOSPC path without changing the product payload ceiling, followed by packaged cancellation and sudden power-loss/directory-entry durability evidence at `stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`. Windows directory-entry durability remains unclaimed until a native primitive and recovery experiment support that assertion. From 64466c782ae244799afad76c3e02fe20e7a5d6ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:00:22 +0900 Subject: [PATCH 120/166] test(score): reproduce real macOS ENOSPC publication failure --- .../score_pdf_capacity_exhaustion_recovery.rs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs new file mode 100644 index 000000000..d97011127 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs @@ -0,0 +1,179 @@ +#[cfg(target_os = "macos")] +use bandscope_desktop_core::{ + inventory_published_score_pdf_receipts, publish_score_pdf_attachment, +}; +#[cfg(target_os = "macos")] +use std::{ + env, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(target_os = "macos")] +const SCORE_ID: &str = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; +#[cfg(target_os = "macos")] +const SOURCE_BYTES: usize = 1024 * 1024; +#[cfg(target_os = "macos")] +const RESERVE_BYTES: usize = 512 * 1024; +#[cfg(target_os = "macos")] +const FILL_BLOCK_BYTES: usize = 64 * 1024; +#[cfg(target_os = "macos")] +const ENOSPC: i32 = 28; + +#[cfg(target_os = "macos")] +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + env::temp_dir().join(format!("bandscope-score-capacity-{name}-{suffix}")) +} + +#[cfg(target_os = "macos")] +fn pdf_fixture(bytes: usize) -> Vec { + let mut fixture = vec![b'x'; bytes]; + fixture[..9].copy_from_slice(b"%PDF-1.7\n"); + fixture +} + +#[cfg(target_os = "macos")] +fn write_allocated_file(path: &Path, bytes: usize) { + let mut file = File::create(path).expect("capacity fixture should be created"); + let block = vec![0_u8; FILL_BLOCK_BYTES]; + let mut remaining = bytes; + while remaining > 0 { + let count = remaining.min(block.len()); + file.write_all(&block[..count]) + .expect("capacity fixture should allocate its requested bytes"); + remaining -= count; + } + file.sync_all() + .expect("capacity fixture should be synchronized before exhaustion"); +} + +#[cfg(target_os = "macos")] +fn fill_until_enospc(path: &Path) { + let mut filler = File::create(path).expect("capacity filler should be created"); + let block = vec![0_u8; FILL_BLOCK_BYTES]; + + for _ in 0..1024 { + match filler.write_all(&block) { + Ok(()) => continue, + Err(error) => { + assert_eq!( + error.raw_os_error(), + Some(ENOSPC), + "the isolated filesystem must fail from real capacity exhaustion" + ); + return; + } + } + } + + panic!("the fixed-size disk image did not reach ENOSPC within the bounded fill"); +} + +#[cfg(target_os = "macos")] +struct MountedImage { + image: PathBuf, + mountpoint: PathBuf, + host_root: PathBuf, +} + +#[cfg(target_os = "macos")] +impl MountedImage { + fn create(host_root: PathBuf) -> Self { + fs::create_dir_all(&host_root).expect("capacity host root should be created"); + let image = host_root.join("capacity.dmg"); + let mountpoint = host_root.join("volume"); + fs::create_dir(&mountpoint).expect("capacity mountpoint should be created"); + + let create = Command::new("hdiutil") + .arg("create") + .args(["-size", "16m", "-fs", "HFS+", "-volname", "BandScopeENOSPC"]) + .args(["-format", "UDRW"]) + .arg(&image) + .status() + .expect("hdiutil create should launch"); + assert!(create.success(), "fixed-size capacity image should be created"); + + let attach = Command::new("hdiutil") + .arg("attach") + .arg(&image) + .arg("-nobrowse") + .arg("-mountpoint") + .arg(&mountpoint) + .status() + .expect("hdiutil attach should launch"); + assert!(attach.success(), "fixed-size capacity image should mount"); + + Self { + image, + mountpoint, + host_root, + } + } +} + +#[cfg(target_os = "macos")] +impl Drop for MountedImage { + fn drop(&mut self) { + let _ = Command::new("hdiutil") + .arg("detach") + .arg(&self.mountpoint) + .arg("-force") + .status(); + let _ = fs::remove_file(&self.image); + let _ = fs::remove_dir_all(&self.host_root); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn actual_capacity_exhaustion_does_not_create_published_score_truth() { + let host_root = unique_test_dir("enospc"); + fs::create_dir_all(&host_root).expect("capacity host root should be created"); + let source = host_root.join("selected.pdf"); + fs::write(&source, pdf_fixture(SOURCE_BYTES)).expect("source PDF should be written on host disk"); + + let mounted = MountedImage::create(host_root.clone()); + let scores_root = mounted.mountpoint.clone(); + let reserve = scores_root.join("reserve.bin"); + let filler = scores_root.join("filler.bin"); + + write_allocated_file(&reserve, RESERVE_BYTES); + fill_until_enospc(&filler); + fs::remove_file(&reserve).expect("removing the fixed reserve should free bounded capacity"); + + let error = publish_score_pdf_attachment(&source, &scores_root, SCORE_ID) + .expect_err("a 1 MiB score must not fit in the bounded capacity released by the reserve"); + assert_eq!(error, "Could not attach the score PDF."); + + let stage = scores_root.join(format!(".score-{SCORE_ID}.stage")); + let destination = scores_root.join(format!("{SCORE_ID}.pdf")); + assert!( + !destination.exists(), + "ENOSPC during staged copy must not create published score truth" + ); + assert!( + !stage.exists(), + "the failed writer must retire only its owned partial stage" + ); + assert_eq!( + fs::metadata(&source) + .expect("source should remain intact after capacity exhaustion") + .len(), + SOURCE_BYTES as u64, + "capacity fault injection must not shrink the product source or its 25 MiB ceiling" + ); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("fresh restart inventory should accept the cleaned capacity-fault workspace"); + assert!( + receipts.is_empty(), + "capacity exhaustion must not become a published recovery candidate" + ); +} From 16f559cf38d30d5548426262cd4614459fc3e8ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:00:43 +0900 Subject: [PATCH 121/166] ci(score): execute capacity-exhaustion recovery regression --- .github/workflows/score-storage-native.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index a374a5d2d..ec0187dd3 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -93,7 +93,7 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel - - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, write-failure, Windows ACL, Unix cleanup, and wiring regressions + - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, write-failure, capacity-exhaustion, Windows ACL, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml @@ -106,6 +106,7 @@ jobs: --test score_pdf_success_durability --test score_pdf_fault_recovery --test score_pdf_write_failure_recovery + --test score_pdf_capacity_exhaustion_recovery --test score_pdf_windows_acl_recovery --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring From b6922153583543cf898e8bec0f3941408f551003 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:01:35 +0900 Subject: [PATCH 122/166] test(score): synchronize fixed-volume exhaustion fixture --- .../score_pdf_capacity_exhaustion_recovery.rs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs index d97011127..1bcceac27 100644 --- a/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs +++ b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs @@ -54,22 +54,28 @@ fn write_allocated_file(path: &Path, bytes: usize) { .expect("capacity fixture should be synchronized before exhaustion"); } +#[cfg(target_os = "macos")] +fn assert_enospc(error: &std::io::Error) { + assert_eq!( + error.raw_os_error(), + Some(ENOSPC), + "the isolated filesystem must fail from real capacity exhaustion" + ); +} + #[cfg(target_os = "macos")] fn fill_until_enospc(path: &Path) { let mut filler = File::create(path).expect("capacity filler should be created"); let block = vec![0_u8; FILL_BLOCK_BYTES]; for _ in 0..1024 { - match filler.write_all(&block) { - Ok(()) => continue, - Err(error) => { - assert_eq!( - error.raw_os_error(), - Some(ENOSPC), - "the isolated filesystem must fail from real capacity exhaustion" - ); - return; - } + if let Err(error) = filler.write_all(&block) { + assert_enospc(&error); + return; + } + if let Err(error) = filler.sync_data() { + assert_enospc(&error); + return; } } From 3c64fb31bd09326b10bb16313cc42edbb0a19e85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:02:23 +0900 Subject: [PATCH 123/166] test(score): repair macOS capacity image provisioning --- .../desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs index 1bcceac27..8369ed994 100644 --- a/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs +++ b/apps/desktop/core/tests/score_pdf_capacity_exhaustion_recovery.rs @@ -100,7 +100,6 @@ impl MountedImage { let create = Command::new("hdiutil") .arg("create") .args(["-size", "16m", "-fs", "HFS+", "-volname", "BandScopeENOSPC"]) - .args(["-format", "UDRW"]) .arg(&image) .status() .expect("hdiutil create should launch"); From 18413751d27c67f63fe33cb9c94eb375ca6ab845 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 02:04:31 +0900 Subject: [PATCH 124/166] docs(score): trace actual ENOSPC recovery evidence --- .../score-attachment-fault-recovery.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index f0b58d064..6fa133ebf 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -8,7 +8,7 @@ Related owner: Project Persistence #970 Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults that occur before publication as well as faults that leave an indeterminate reserved stage. -Three cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. These states must never invent a published score object, and an indeterminate stage must not be deleted on pathname authority alone. +Four cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. Actual filesystem capacity exhaustion can also make that writer fail after a real `ENOSPC`, which must be distinguished from an artificial file-size limit. None of these states may invent a published score object, and an indeterminate stage must not be deleted on pathname authority alone. ## Repair lineage @@ -16,9 +16,11 @@ Three cases are owned here. An interrupted fixed-buffer copy may leave incomplet Windows ACL denial is covered separately by `score-attachment-windows-acl-recovery.md` and its Windows Server 2025 regression. -`22779b70c54109689aebcc22edde450778d2cc7e` adds `score_pdf_write_failure_recovery.rs`. The test drives the public `publish_score_pdf_attachment` path in a child process whose kernel file-size limit is reduced with POSIX `ulimit -f`. The source PDF is created before the limit is installed and remains 128 KiB; no production size constant or publication ceiling is changed. The child ignores `SIGXFSZ` so the write returns through the normal Rust I/O error path instead of terminating the process. At that test-only head the `score_pdf_*.rs` path filter triggered the owner workflow, but the explicit `cargo test` list did not execute the new regression. That is a source-level owner-evidence RED, not a hosted behavioral RED. +`22779b70c54109689aebcc22edde450778d2cc7e` added `score_pdf_write_failure_recovery.rs`. The test drives the public `publish_score_pdf_attachment` path in a child process whose kernel file-size limit is reduced with POSIX `ulimit -f`. The source PDF is created before the limit is installed and remains 128 KiB; no production size constant or publication ceiling is changed. The child ignores `SIGXFSZ` so the write returns through the normal Rust I/O error path instead of terminating the process. At that test-only head the `score_pdf_*.rs` path filter triggered the owner workflow, but the explicit `cargo test` list did not execute the new regression. That is a source-level owner-evidence RED, not a hosted behavioral RED. `c1ff55f277899d8a98015a2f6b6063308bb1017d` repaired the owner workflow by adding that target to the macOS 15 / Windows Server 2025 invocation. -`c1ff55f277899d8a98015a2f6b6063308bb1017d` repairs the owner workflow by adding `--test score_pdf_write_failure_recovery` to the macOS 15 / Windows Server 2025 invocation. The behavioral assertion is Unix-only; Windows compiles the integration-test crate with zero platform tests for this case. +`64466c782ae244799afad76c3e02fe20e7a5d6ae` added `score_pdf_capacity_exhaustion_recovery.rs`. On macOS it creates a fixed-size 16 MiB HFS+ disk image, writes and synchronizes filler blocks until the OS reports raw `ENOSPC` (`28`), releases only a 512 KiB reserve, and then sends a 1 MiB PDF-shaped host source through the public Score Storage publisher. This preserves the 25 MiB production ceiling while making the destination filesystem genuinely too small for the selected score. + +`16f559cf38d30d5548426262cd4614459fc3e8ea` added the new regression to the explicit owner workflow. That exact head produced a real hosted failure in `score-storage-native` run `35524498229`, macOS job `106114188950`, before the product boundary was reached: current macOS `hdiutil create` rejected the fixture's `-format UDRW` combination with `-fs` unless a source folder/device was supplied. This was a fixture-provisioning RCA, not a Score Storage behavioral failure. `b6922153583543cf898e8bec0f3941408f551003` also made each capacity-filler block call `sync_data()` so delayed allocation cannot turn the test into a page-cache-only approximation. `3c64fb31bd09326b10bb16313cc42edbb0a19e85` removed the invalid image-format argument. Exact head `3c64fb31bd09326b10bb16313cc42edbb0a19e85` then completed owner run `35524589689` successfully on macOS 15 job `106114428123` and Windows Server 2025 job `106114428002`; the macOS lane executes the ENOSPC behavior and the Windows target is platform-gated while the rest of the native owner suite still runs. ## Fault semantics @@ -26,9 +28,9 @@ The interrupted-copy fixture leaves only `%PD` in an exact reserved `.score-.pdf` exists, the writer-owned partial stage was retired, the source length is unchanged, and fresh restart inventory is empty. +The kernel write-failure regression exercises another boundary. The parent creates a 128 KiB PDF-shaped source, then starts the current integration-test binary in a child process with a 512-byte file-size limit. The production publisher opens the real private stage and encounters the kernel write failure while copying. The child must return the generic attachment error rather than crash or report success. After the child exits, the parent verifies that no `.pdf` exists, the writer-owned partial stage was retired, the source length is unchanged, and fresh restart inventory is empty. -This is intentionally not described as an ENOSPC or physical-disk-full test. `RLIMIT_FSIZE`/`ulimit -f` produces a kernel-enforced write failure without requiring privileged filesystem provisioning and without changing BandScope's 25 MiB product boundary. Actual capacity exhaustion remains separate acceptance evidence. +The capacity-exhaustion regression is deliberately different from `RLIMIT_FSIZE`. The selected 1 MiB source lives outside the isolated image. The test fills and synchronizes the fixed-size image until a real `ENOSPC` is observed, removes only a 512 KiB reserve, and invokes the public publisher. Publication must fail before destination truth is created; the owned partial stage must be retired, the source must remain 1 MiB, and restart inventory must remain empty. The source size is a fault-fixture choice, not a reduction of the 25 MiB product ceiling. ## Security Notes @@ -36,10 +38,10 @@ This is intentionally not described as an ENOSPC or physical-disk-full test. `RL **Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. -**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. +**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write or capacity failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. -**Claim boundary.** Current owner evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, and a Unix kernel-enforced write failure on the real publication path. It does not prove ENOSPC/capacity exhaustion, packaged cancellation, sudden power loss, or Windows directory-entry successful-return durability. +**Claim boundary.** Current owner evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, and macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path. It does not prove packaged cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. ## Next buyer gap -The next storage fault evidence should target actual capacity exhaustion or another OS-native ENOSPC path without changing the product payload ceiling, followed by packaged cancellation and sudden power-loss/directory-entry durability evidence at `stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`. Windows directory-entry durability remains unclaimed until a native primitive and recovery experiment support that assertion. +The next fault evidence should move to packaged cancellation and process termination around `stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`, then to sudden-power-loss/directory-entry durability. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race also remains an explicit storage-authority gap. From e5323d3eec9c5eb2e89ac688930a84bebd1ce430 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:07:52 +0900 Subject: [PATCH 125/166] test(score): define owner-only fault injection feature --- apps/desktop/core/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index 44f482e73..5395e68f0 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -9,6 +9,9 @@ publish = false name = "bandscope_desktop_core" path = "src/root.rs" +[features] +score-storage-fault-injection = [] + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } From 814272038c8b1d522a16ef4a5d707ff9a8cc474d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:08:13 +0900 Subject: [PATCH 126/166] test(score): require publisher termination checkpoint before metadata barrier --- .../score_pdf_process_termination_recovery.rs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_process_termination_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs b/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs new file mode 100644 index 000000000..906386637 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs @@ -0,0 +1,125 @@ +#![cfg(feature = "score-storage-fault-injection")] + +use bandscope_desktop_core::{ + inventory_published_score_pdf_receipts, publish_score_pdf_attachment, +}; +use std::{ + path::PathBuf, + process::{Child, Command}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const SCORE_ID: &str = "0f3b6ce0-43c8-4c64-a8ec-cf905a8bd0d1"; +const CHILD_ENV: &str = "BANDSCOPE_SCORE_PUBLISHER_TERMINATION_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_PUBLISHER_TERMINATION_ROOT"; +const SOURCE_ENV: &str = "BANDSCOPE_SCORE_PUBLISHER_TERMINATION_SOURCE"; +const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; +const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; +const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +fn wait_for_checkpoint(child: &mut Child, marker: &PathBuf) { + let deadline = Instant::now() + Duration::from_secs(15); + while !marker.exists() && Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .expect("publisher child status should be observable") + { + panic!("publisher child exited before the requested checkpoint: {status}"); + } + thread::sleep(Duration::from_millis(20)); + } + if !marker.exists() { + let _ = child.kill(); + let _ = child.wait(); + panic!("publisher child did not expose the requested checkpoint"); + } +} + +#[test] +fn production_publisher_process_termination_before_metadata_barrier_is_recoverable() { + if std::env::var_os(CHILD_ENV).is_some() { + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("publisher child root should be supplied"), + ); + let source = PathBuf::from( + std::env::var_os(SOURCE_ENV).expect("publisher child source should be supplied"), + ); + publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect("publisher child should reach the injected checkpoint before returning"); + panic!("publisher child unexpectedly returned instead of waiting at the checkpoint"); + } + + let root = unique_test_dir("score-publisher-process-termination"); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + let marker = root.join("publisher-checkpoint-ready"); + let expected = b"%PDF-1.7\nproduction publisher termination fixture"; + std::fs::write(&source, expected).expect("score fixture should be written"); + + let test_binary = std::env::current_exe().expect("integration test binary should resolve"); + let mut child = Command::new(test_binary) + .arg("--exact") + .arg("production_publisher_process_termination_before_metadata_barrier_is_recoverable") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env(ROOT_ENV, &root) + .env(SOURCE_ENV, &source) + .env(CHECKPOINT_ENV, BEFORE_METADATA_BARRIER) + .env(MARKER_ENV, &marker) + .spawn() + .expect("publisher child should start"); + + wait_for_checkpoint(&mut child, &marker); + + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + assert!( + !stage.exists(), + "the lower publisher must retire the temporary alias before the metadata barrier" + ); + assert!( + destination.exists(), + "the destination must already be published before the metadata barrier" + ); + assert_eq!( + std::fs::read(&destination).expect("published destination should remain readable"), + expected + ); + assert!( + inventory_published_score_pdf_receipts(&root).is_err(), + "the live publisher must still hold the Score Storage workspace lease at the checkpoint" + ); + + child + .kill() + .expect("publisher child should be terminated at the durability boundary"); + let status = child.wait().expect("publisher child should be reaped"); + assert!( + !status.success(), + "the publisher child must end by process termination rather than successful return" + ); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("a fresh process should recover the published object after termination"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), SCORE_ID); + assert!( + !stage.exists(), + "restart recovery must not recreate a retired staging alias" + ); + assert_eq!( + std::fs::read(&destination).expect("published destination should survive recovery"), + expected + ); + + let _ = std::fs::remove_dir_all(root); +} From a23313b060d14daf691b7c3489be355c4adb6e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:08:31 +0900 Subject: [PATCH 127/166] test(score): run publisher termination regression in native owner lane --- .github/workflows/score-storage-native.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index ec0187dd3..29cb255ec 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -110,3 +110,9 @@ jobs: --test score_pdf_windows_acl_recovery --test score_pdf_unix_stage_cleanup_contract --test score_pdf_attachment_wiring + - name: Run production-publisher process-termination recovery regression + run: >- + cargo +1.97.1 test + --manifest-path apps/desktop/core/Cargo.toml + --features score-storage-fault-injection + --test score_pdf_process_termination_recovery From 5a571e8c018f503ddbd0633d00fd4554d02a9da0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:08:55 +0900 Subject: [PATCH 128/166] fix(score): expose deterministic pre-barrier termination checkpoint --- apps/desktop/core/src/score_publication.rs | 76 ++++++++++++++++++---- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/desktop/core/src/score_publication.rs b/apps/desktop/core/src/score_publication.rs index a7e4ec2bc..b37ac3458 100644 --- a/apps/desktop/core/src/score_publication.rs +++ b/apps/desktop/core/src/score_publication.rs @@ -1,6 +1,48 @@ use std::path::Path; const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; +const BEFORE_METADATA_BARRIER_CHECKPOINT: &str = "before-metadata-barrier"; + +#[cfg(feature = "score-storage-fault-injection")] +fn wait_at_fault_checkpoint(checkpoint: &str) { + use std::{ + ffi::OsStr, + fs::OpenOptions, + io::Write, + path::PathBuf, + thread, + time::Duration, + }; + + const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; + const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; + + if std::env::var_os(CHECKPOINT_ENV).as_deref() != Some(OsStr::new(checkpoint)) { + return; + } + + let marker = PathBuf::from( + std::env::var_os(MARKER_ENV).expect("fault-injection marker path should be supplied"), + ); + let mut marker_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(marker) + .expect("fault-injection marker should be created once"); + marker_file + .write_all(checkpoint.as_bytes()) + .expect("fault-injection marker should be written"); + marker_file + .sync_all() + .expect("fault-injection marker should be synchronized"); + + loop { + thread::sleep(Duration::from_secs(60)); + } +} + +#[cfg(not(feature = "score-storage-fault-injection"))] +fn wait_at_fault_checkpoint(_checkpoint: &str) {} #[cfg(target_os = "macos")] const SYNC_VOLUME_FULLSYNC: i32 = 0x01; @@ -12,15 +54,8 @@ extern "C" { fn fsync_volume_np(fd: i32, flags: i32) -> i32; } -/// Complete the successful-publication metadata durability barrier for the -/// supported platform while the Score Storage workspace lease is still held. -/// -/// This function does not publish or remove any object. Its caller owns the -/// lifecycle transaction and must keep the Score Storage lease alive across the -/// lower-level publication and this barrier so another current-contract process -/// cannot mutate the workspace in between. #[cfg(target_os = "macos")] -pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { +fn sync_successful_publication_metadata_platform(scores_root: &Path) -> Result<(), String> { use std::{fs::File, os::fd::AsRawFd}; let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; @@ -48,7 +83,7 @@ pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result } #[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { +fn sync_successful_publication_metadata_platform(scores_root: &Path) -> Result<(), String> { use std::fs::File; let directory = File::open(scores_root).map_err(|_| SCORE_ATTACH_ERROR.to_string())?; @@ -65,7 +100,7 @@ pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result } #[cfg(windows)] -pub(crate) fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { +fn sync_successful_publication_metadata_platform(_scores_root: &Path) -> Result<(), String> { // The lower Score Storage publisher already synchronizes the staged file // before creating the destination link. Windows directory-entry durability // still needs a separately verified native contract; do not fabricate one @@ -74,10 +109,29 @@ pub(crate) fn sync_successful_publication_metadata(_scores_root: &Path) -> Resul } #[cfg(all(not(unix), not(windows)))] -pub(crate) fn sync_successful_publication_metadata(_scores_root: &Path) -> Result<(), String> { +fn sync_successful_publication_metadata_platform(_scores_root: &Path) -> Result<(), String> { Err(SCORE_ATTACH_ERROR.to_string()) } +/// Complete the successful-publication metadata durability barrier for the +/// supported platform while the Score Storage workspace lease is still held. +/// +/// This function does not publish or remove any object. Its caller owns the +/// lifecycle transaction and must keep the Score Storage lease alive across the +/// lower-level publication and this barrier so another current-contract process +/// cannot mutate the workspace in between. +/// +/// The optional `score-storage-fault-injection` feature exists only for the +/// owner-native process-termination regression. When that feature and its exact +/// checkpoint environment are both present, execution pauses immediately before +/// the platform barrier so a parent test process can terminate the real public +/// publisher while the workspace lease is still held. Default product builds do +/// not include an active checkpoint path. +pub(crate) fn sync_successful_publication_metadata(scores_root: &Path) -> Result<(), String> { + wait_at_fault_checkpoint(BEFORE_METADATA_BARRIER_CHECKPOINT); + sync_successful_publication_metadata_platform(scores_root) +} + #[cfg(test)] mod tests { use super::*; From c12bf5d4a85771d02855550ac762fcf5ee8e25d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:10:18 +0900 Subject: [PATCH 129/166] docs(score): trace real publisher pre-barrier termination boundary --- .../score-attachment-fault-recovery.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index 6fa133ebf..51037e4d9 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -8,7 +8,7 @@ Related owner: Project Persistence #970 Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults that occur before publication as well as faults that leave an indeterminate reserved stage. -Four cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. Actual filesystem capacity exhaustion can also make that writer fail after a real `ENOSPC`, which must be distinguished from an artificial file-size limit. None of these states may invent a published score object, and an indeterminate stage must not be deleted on pathname authority alone. +Five cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. Actual filesystem capacity exhaustion can also make that writer fail after a real `ENOSPC`, which must be distinguished from an artificial file-size limit. Finally, the real public publisher can be terminated after the destination has been published and the stage alias retired but before the successful-return metadata barrier. None of these states may invent a durable project reference, delete buyer bytes on pathname authority, or misreport an interrupted publisher as a successful attachment. ## Repair lineage @@ -22,6 +22,8 @@ Windows ACL denial is covered separately by `score-attachment-windows-acl-recove `16f559cf38d30d5548426262cd4614459fc3e8ea` added the new regression to the explicit owner workflow. That exact head produced a real hosted failure in `score-storage-native` run `35524498229`, macOS job `106114188950`, before the product boundary was reached: current macOS `hdiutil create` rejected the fixture's `-format UDRW` combination with `-fs` unless a source folder/device was supplied. This was a fixture-provisioning RCA, not a Score Storage behavioral failure. `b6922153583543cf898e8bec0f3941408f551003` also made each capacity-filler block call `sync_data()` so delayed allocation cannot turn the test into a page-cache-only approximation. `3c64fb31bd09326b10bb16313cc42edbb0a19e85` removed the invalid image-format argument. Exact head `3c64fb31bd09326b10bb16313cc42edbb0a19e85` then completed owner run `35524589689` successfully on macOS 15 job `106114428123` and Windows Server 2025 job `106114428002`; the macOS lane executes the ENOSPC behavior and the Windows target is platform-gated while the rest of the native owner suite still runs. +`e5323d3eec9c5eb2e89ac688930a84bebd1ce430` defines the owner-only `score-storage-fault-injection` Cargo feature. `814272038c8b1d522a16ef4a5d707ff9a8cc474d` adds `score_pdf_process_termination_recovery.rs`, whose child process invokes the real public `publish_score_pdf_attachment` API rather than constructing a reserved stage itself. `a23313b060d14daf691b7c3489be355c4adb6e04` makes that regression an explicit owner-native workflow target. At that test-only head no product checkpoint existed, so the child would return before exposing the requested `before-metadata-barrier` readiness marker; this is the behavioral source-level RED. Its owner run `35534584400` did not settle to a terminal hosted verdict before the repair descendant existed, so no hosted RED is claimed. `5a571e8c018f503ddbd0633d00fd4554d02a9da0` adds the deterministic checkpoint immediately before the platform successful-return metadata barrier. The checkpoint is compiled only when the owner-only feature is enabled and activates only for the exact fault-injection environment; default product builds have a no-op path. + ## Fault semantics The interrupted-copy fixture leaves only `%PD` in an exact reserved `.score-.stage` name. This represents a process disappearing before the descriptor-bounded copy completes. Restart inventory retires that owned temporary residue and returns no published receipt. @@ -32,16 +34,18 @@ The kernel write-failure regression exercises another boundary. The parent creat The capacity-exhaustion regression is deliberately different from `RLIMIT_FSIZE`. The selected 1 MiB source lives outside the isolated image. The test fills and synchronizes the fixed-size image until a real `ENOSPC` is observed, removes only a 512 KiB reserve, and invokes the public publisher. Publication must fail before destination truth is created; the owned partial stage must be retired, the source must remain 1 MiB, and restart inventory must remain empty. The source size is a fault-fixture choice, not a reduction of the 25 MiB product ceiling. +The process-termination regression exercises the opposite side of publication. With the owner-only feature enabled, the real public publisher reaches a deterministic checkpoint only after the lower publisher has synchronized and published the destination, retired the temporary stage alias and completed final destination identity attestation, but before the platform successful-return metadata barrier runs. The checkpoint is inside the Score Storage workspace lease. The parent verifies `stage absent + destination present + live inventory rejected by lease`, terminates the publisher process, and then requires a fresh inventory call to recover one path-free published-object receipt while preserving the destination bytes. This proves recovery semantics for a process killed at that exact owner boundary; it does not prove sudden-power-loss durability or that project metadata had accepted the attachment. + ## Security Notes **Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. -**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. The process-termination checkpoint is test-only instrumentation behind an explicit Cargo feature and exact environment selector; it does not add a runtime command, network surface or production mutation authority. -**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write or capacity failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. +**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write or capacity failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. A process killed after stage retirement but before the successful-return barrier leaves published object truth for restart inventory, not a fabricated project reference or a guessed delete. -**Claim boundary.** Current owner evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, and macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path. It does not prove packaged cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. +**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path, and owner-native process termination of the real public publisher immediately before the successful-return metadata barrier. The latest process-termination source has not yet been promoted to hosted GREEN in this document. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. ## Next buyer gap -The next fault evidence should move to packaged cancellation and process termination around `stage sync -> destination publication -> stage retirement -> successful-return metadata barrier`, then to sudden-power-loss/directory-entry durability. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race also remains an explicit storage-authority gap. +After the current exact-head owner workflow settles, process-termination evidence should move earlier in the actual publisher lifecycle so stage-synchronized/pre-link and post-link/pre-stage-retirement boundaries are driven by the real publisher rather than hand-built filesystem fixtures. A packaged desktop-executable cancellation path is still separate from an owner-native integration-test child. Sudden-power-loss/directory-entry durability follows after that. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race also remains an explicit storage-authority gap. From 61f94a4b076d83109703aedde51f73f1eb9e47e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:11:26 +0900 Subject: [PATCH 130/166] fix(score): fail closed if fault injection reaches release builds --- apps/desktop/core/src/root.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/core/src/root.rs b/apps/desktop/core/src/root.rs index 9eb3a530a..307a5a65f 100644 --- a/apps/desktop/core/src/root.rs +++ b/apps/desktop/core/src/root.rs @@ -5,6 +5,11 @@ //! modules. Public symbols are re-exported so downstream callers keep the same //! crate-root API. +#[cfg(all(feature = "score-storage-fault-injection", not(debug_assertions)))] +compile_error!( + "score-storage-fault-injection is owner-test instrumentation and must not be enabled in release builds" +); + #[path = "lib.rs"] mod runtime_core; mod content_sha256; From 03b6b3c14f65ba80dcfeb0a5180dcc8b4c9d8543 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:11:44 +0900 Subject: [PATCH 131/166] test(score): prove fault injection cannot ship in release builds --- .github/workflows/score-storage-native.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 29cb255ec..c018e1156 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -17,6 +17,7 @@ on: - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" + - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" @@ -43,6 +44,7 @@ on: - "apps/desktop/core/src/score_storage.rs" - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" + - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" @@ -93,6 +95,24 @@ jobs: cargo +1.97.1 test \ --manifest-path apps/desktop/core/Cargo.toml \ --test content_sha256_shared_kernel + - name: Verify shipped desktop excludes Score Storage fault injection + shell: bash + run: | + feature_tree="$(cargo +1.97.1 tree \ + --manifest-path apps/desktop/src-tauri/Cargo.toml \ + -e features \ + -i bandscope-desktop-core)" + if printf '%s\n' "$feature_tree" | grep -F 'score-storage-fault-injection'; then + echo "::error::The shipped desktop dependency graph enables score-storage-fault-injection." + exit 1 + fi + if cargo +1.97.1 check \ + --release \ + --manifest-path apps/desktop/core/Cargo.toml \ + --features score-storage-fault-injection; then + echo "::error::A release build accepted the owner-only fault-injection feature." + exit 1 + fi - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, write-failure, capacity-exhaustion, Windows ACL, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test From 0be5683c2a7ea17d89e0b5deeb88c5e0bea0134d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:12:14 +0900 Subject: [PATCH 132/166] docs(score): bind termination fault injection to release exclusion --- docs/traceability/score-attachment-fault-recovery.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index 51037e4d9..60654312a 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -22,7 +22,9 @@ Windows ACL denial is covered separately by `score-attachment-windows-acl-recove `16f559cf38d30d5548426262cd4614459fc3e8ea` added the new regression to the explicit owner workflow. That exact head produced a real hosted failure in `score-storage-native` run `35524498229`, macOS job `106114188950`, before the product boundary was reached: current macOS `hdiutil create` rejected the fixture's `-format UDRW` combination with `-fs` unless a source folder/device was supplied. This was a fixture-provisioning RCA, not a Score Storage behavioral failure. `b6922153583543cf898e8bec0f3941408f551003` also made each capacity-filler block call `sync_data()` so delayed allocation cannot turn the test into a page-cache-only approximation. `3c64fb31bd09326b10bb16313cc42edbb0a19e85` removed the invalid image-format argument. Exact head `3c64fb31bd09326b10bb16313cc42edbb0a19e85` then completed owner run `35524589689` successfully on macOS 15 job `106114428123` and Windows Server 2025 job `106114428002`; the macOS lane executes the ENOSPC behavior and the Windows target is platform-gated while the rest of the native owner suite still runs. -`e5323d3eec9c5eb2e89ac688930a84bebd1ce430` defines the owner-only `score-storage-fault-injection` Cargo feature. `814272038c8b1d522a16ef4a5d707ff9a8cc474d` adds `score_pdf_process_termination_recovery.rs`, whose child process invokes the real public `publish_score_pdf_attachment` API rather than constructing a reserved stage itself. `a23313b060d14daf691b7c3489be355c4adb6e04` makes that regression an explicit owner-native workflow target. At that test-only head no product checkpoint existed, so the child would return before exposing the requested `before-metadata-barrier` readiness marker; this is the behavioral source-level RED. Its owner run `35534584400` did not settle to a terminal hosted verdict before the repair descendant existed, so no hosted RED is claimed. `5a571e8c018f503ddbd0633d00fd4554d02a9da0` adds the deterministic checkpoint immediately before the platform successful-return metadata barrier. The checkpoint is compiled only when the owner-only feature is enabled and activates only for the exact fault-injection environment; default product builds have a no-op path. +`e5323d3eec9c5eb2e89ac688930a84bebd1ce430` defines the owner-only `score-storage-fault-injection` Cargo feature. `814272038c8b1d522a16ef4a5d707ff9a8cc474d` adds `score_pdf_process_termination_recovery.rs`, whose child process invokes the real public `publish_score_pdf_attachment` API rather than constructing a reserved stage itself. `a23313b060d14daf691b7c3489be355c4adb6e04` makes that regression an explicit owner-native workflow target. At that test-only head no product checkpoint existed, so the child would return before exposing the requested `before-metadata-barrier` readiness marker; this is the behavioral source-level RED. Its owner run `35534584400` did not settle to a terminal hosted verdict before the repair descendant existed, so no hosted RED is claimed. `5a571e8c018f503ddbd0633d00fd4554d02a9da0` adds the deterministic checkpoint immediately before the platform successful-return metadata barrier. The checkpoint is compiled only when the owner-only feature is enabled and activates only for the exact fault-injection environment. + +Review of that repair found a second release-safety requirement: a deliberate indefinite wait must never become reachable in a shipped build merely because a feature is accidentally enabled. `61f94a4b076d83109703aedde51f73f1eb9e47e3` therefore adds a compile-time fail-closed guard for `score-storage-fault-injection` whenever `debug_assertions` are absent. `03b6b3c14f65ba80dcfeb0a5180dcc8b4c9d8543` extends the owner workflow with two release-inventory checks: the normal desktop dependency feature graph must not contain `score-storage-fault-injection`, and a release-mode core build that explicitly enables the owner-only feature must be rejected by the compile guard. The normal owner-native termination regression continues to enable the feature only in its debug test command. ## Fault semantics @@ -40,11 +42,11 @@ The process-termination regression exercises the opposite side of publication. W **Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. -**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. The process-termination checkpoint is test-only instrumentation behind an explicit Cargo feature and exact environment selector; it does not add a runtime command, network surface or production mutation authority. +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. The process-termination checkpoint is owner-test instrumentation behind an explicit Cargo feature and exact environment selector; it does not add a runtime command, network surface or product mutation authority. Release builds fail to compile if the feature is enabled, and the desktop dependency feature graph is checked separately so the shipped manifest cannot silently opt into it. **Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write or capacity failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. A process killed after stage retirement but before the successful-return barrier leaves published object truth for restart inventory, not a fabricated project reference or a guessed delete. -**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path, and owner-native process termination of the real public publisher immediately before the successful-return metadata barrier. The latest process-termination source has not yet been promoted to hosted GREEN in this document. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. +**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path, and owner-native process termination of the real public publisher immediately before the successful-return metadata barrier. The latest process-termination and release-feature exclusion source has not yet been promoted to hosted GREEN in this document. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. ## Next buyer gap From 8f3186938c7754a24cbfdd31fbdcfa81a1415eea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:16:37 +0900 Subject: [PATCH 133/166] test(score): require real publisher termination at all publication boundaries --- .../score_pdf_process_termination_recovery.rs | 187 +++++++++++++----- 1 file changed, 134 insertions(+), 53 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs b/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs index 906386637..ff41a69b9 100644 --- a/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs +++ b/apps/desktop/core/tests/score_pdf_process_termination_recovery.rs @@ -16,7 +16,10 @@ const ROOT_ENV: &str = "BANDSCOPE_SCORE_PUBLISHER_TERMINATION_ROOT"; const SOURCE_ENV: &str = "BANDSCOPE_SCORE_PUBLISHER_TERMINATION_SOURCE"; const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; +const AFTER_STAGE_SYNC_BEFORE_LINK: &str = "after-stage-sync-before-link"; +const AFTER_LINK_BEFORE_STAGE_RETIREMENT: &str = "after-link-before-stage-retirement"; const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; +const EXPECTED: &[u8] = b"%PDF-1.7\nproduction publisher termination fixture"; fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() @@ -26,6 +29,21 @@ fn unique_test_dir(name: &str) -> PathBuf { std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) } +fn child_publish_if_requested() -> bool { + if std::env::var_os(CHILD_ENV).is_none() { + return false; + } + let root = PathBuf::from( + std::env::var_os(ROOT_ENV).expect("publisher child root should be supplied"), + ); + let source = PathBuf::from( + std::env::var_os(SOURCE_ENV).expect("publisher child source should be supplied"), + ); + publish_score_pdf_attachment(&source, &root, SCORE_ID) + .expect("publisher child should reach the injected checkpoint before returning"); + panic!("publisher child unexpectedly returned instead of waiting at the checkpoint"); +} + fn wait_for_checkpoint(child: &mut Child, marker: &PathBuf) { let deadline = Instant::now() + Duration::from_secs(15); while !marker.exists() && Instant::now() < deadline { @@ -44,82 +62,145 @@ fn wait_for_checkpoint(child: &mut Child, marker: &PathBuf) { } } -#[test] -fn production_publisher_process_termination_before_metadata_barrier_is_recoverable() { - if std::env::var_os(CHILD_ENV).is_some() { - let root = PathBuf::from( - std::env::var_os(ROOT_ENV).expect("publisher child root should be supplied"), - ); - let source = PathBuf::from( - std::env::var_os(SOURCE_ENV).expect("publisher child source should be supplied"), - ); - publish_score_pdf_attachment(&source, &root, SCORE_ID) - .expect("publisher child should reach the injected checkpoint before returning"); - panic!("publisher child unexpectedly returned instead of waiting at the checkpoint"); - } - - let root = unique_test_dir("score-publisher-process-termination"); - std::fs::create_dir_all(&root).expect("score root should be created"); - let source = root.join("selected.pdf"); - let marker = root.join("publisher-checkpoint-ready"); - let expected = b"%PDF-1.7\nproduction publisher termination fixture"; - std::fs::write(&source, expected).expect("score fixture should be written"); - +fn spawn_publisher_at(test_name: &str, checkpoint: &str, root: &PathBuf, source: &PathBuf, marker: &PathBuf) -> Child { let test_binary = std::env::current_exe().expect("integration test binary should resolve"); - let mut child = Command::new(test_binary) + Command::new(test_binary) .arg("--exact") - .arg("production_publisher_process_termination_before_metadata_barrier_is_recoverable") + .arg(test_name) .arg("--nocapture") .env(CHILD_ENV, "1") - .env(ROOT_ENV, &root) - .env(SOURCE_ENV, &source) - .env(CHECKPOINT_ENV, BEFORE_METADATA_BARRIER) - .env(MARKER_ENV, &marker) + .env(ROOT_ENV, root) + .env(SOURCE_ENV, source) + .env(CHECKPOINT_ENV, checkpoint) + .env(MARKER_ENV, marker) .spawn() - .expect("publisher child should start"); + .expect("publisher child should start") +} + +fn terminate_and_reap(child: &mut Child) { + child + .kill() + .expect("publisher child should be terminated at the requested boundary"); + let status = child.wait().expect("publisher child should be reaped"); + assert!( + !status.success(), + "the publisher child must end by process termination rather than successful return" + ); +} + +fn fixture(name: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = unique_test_dir(name); + std::fs::create_dir_all(&root).expect("score root should be created"); + let source = root.join("selected.pdf"); + let marker = root.join("publisher-checkpoint-ready"); + std::fs::write(&source, EXPECTED).expect("score fixture should be written"); + (root, source, marker) +} +#[test] +fn production_publisher_process_termination_after_stage_sync_recovers_stage_only() { + if child_publish_if_requested() { + return; + } + let (root, source, marker) = fixture("score-publisher-stage-sync-termination"); + let mut child = spawn_publisher_at( + "production_publisher_process_termination_after_stage_sync_recovers_stage_only", + AFTER_STAGE_SYNC_BEFORE_LINK, + &root, + &source, + &marker, + ); wait_for_checkpoint(&mut child, &marker); let stage = root.join(format!(".score-{SCORE_ID}.stage")); let destination = root.join(format!("{SCORE_ID}.pdf")); + assert!(stage.exists(), "the synchronized stage must exist before publication"); + assert!(!destination.exists(), "destination truth must not exist before the link"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); assert!( - !stage.exists(), - "the lower publisher must retire the temporary alias before the metadata barrier" - ); - assert!( - destination.exists(), - "the destination must already be published before the metadata barrier" + inventory_published_score_pdf_receipts(&root).is_err(), + "the live publisher must still hold the workspace lease" ); - assert_eq!( - std::fs::read(&destination).expect("published destination should remain readable"), - expected + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("restart recovery should retire the abandoned stage-only state"); + assert!(receipts.is_empty()); + assert!(!stage.exists(), "restart recovery should retire the abandoned stage"); + assert!(!destination.exists(), "restart recovery must not invent destination truth"); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn production_publisher_process_termination_after_link_recovers_equal_aliases() { + if child_publish_if_requested() { + return; + } + let (root, source, marker) = fixture("score-publisher-post-link-termination"); + let mut child = spawn_publisher_at( + "production_publisher_process_termination_after_link_recovers_equal_aliases", + AFTER_LINK_BEFORE_STAGE_RETIREMENT, + &root, + &source, + &marker, ); + wait_for_checkpoint(&mut child, &marker); + + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + assert!(stage.exists(), "the temporary alias must still exist at the post-link boundary"); + assert!(destination.exists(), "the destination must exist after publication link creation"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); assert!( inventory_published_score_pdf_receipts(&root).is_err(), - "the live publisher must still hold the Score Storage workspace lease at the checkpoint" + "the live publisher must still hold the workspace lease" ); - child - .kill() - .expect("publisher child should be terminated at the durability boundary"); - let status = child.wait().expect("publisher child should be reaped"); - assert!( - !status.success(), - "the publisher child must end by process termination rather than successful return" - ); + terminate_and_reap(&mut child); let receipts = inventory_published_score_pdf_receipts(&root) - .expect("a fresh process should recover the published object after termination"); + .expect("restart recovery should retain destination truth and retire the equal stage alias"); assert_eq!(receipts.len(), 1); assert_eq!(receipts[0].score_id(), SCORE_ID); - assert!( - !stage.exists(), - "restart recovery must not recreate a retired staging alias" + assert!(!stage.exists(), "restart recovery should retire only the temporary alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive"), EXPECTED); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn production_publisher_process_termination_before_metadata_barrier_is_recoverable() { + if child_publish_if_requested() { + return; + } + let (root, source, marker) = fixture("score-publisher-pre-barrier-termination"); + let mut child = spawn_publisher_at( + "production_publisher_process_termination_before_metadata_barrier_is_recoverable", + BEFORE_METADATA_BARRIER, + &root, + &source, + &marker, ); - assert_eq!( - std::fs::read(&destination).expect("published destination should survive recovery"), - expected + wait_for_checkpoint(&mut child, &marker); + + let stage = root.join(format!(".score-{SCORE_ID}.stage")); + let destination = root.join(format!("{SCORE_ID}.pdf")); + assert!(!stage.exists(), "the lower publisher must retire the stage before the metadata barrier"); + assert!(destination.exists(), "the destination must already be published before the barrier"); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); + assert!( + inventory_published_score_pdf_receipts(&root).is_err(), + "the live publisher must still hold the workspace lease at the barrier checkpoint" ); + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&root) + .expect("a fresh process should recover the published object after termination"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), SCORE_ID); + assert!(!stage.exists(), "restart recovery must not recreate a retired stage alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive recovery"), EXPECTED); let _ = std::fs::remove_dir_all(root); } From 1a381460d0ee9a219a1bbc871b9db3b078accb41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:17:47 +0900 Subject: [PATCH 134/166] refactor(score): share owner-only termination checkpoint within storage boundary --- apps/desktop/core/src/score_publication.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/score_publication.rs b/apps/desktop/core/src/score_publication.rs index b37ac3458..8790913dc 100644 --- a/apps/desktop/core/src/score_publication.rs +++ b/apps/desktop/core/src/score_publication.rs @@ -4,7 +4,7 @@ const SCORE_ATTACH_ERROR: &str = "Could not attach the score PDF."; const BEFORE_METADATA_BARRIER_CHECKPOINT: &str = "before-metadata-barrier"; #[cfg(feature = "score-storage-fault-injection")] -fn wait_at_fault_checkpoint(checkpoint: &str) { +pub(crate) fn wait_at_fault_checkpoint(checkpoint: &str) { use std::{ ffi::OsStr, fs::OpenOptions, @@ -42,7 +42,7 @@ fn wait_at_fault_checkpoint(checkpoint: &str) { } #[cfg(not(feature = "score-storage-fault-injection"))] -fn wait_at_fault_checkpoint(_checkpoint: &str) {} +pub(crate) fn wait_at_fault_checkpoint(_checkpoint: &str) {} #[cfg(target_os = "macos")] const SYNC_VOLUME_FULLSYNC: i32 = 0x01; From 1f1377c219f885996dccdd81168ae23529fe4a55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:18:56 +0900 Subject: [PATCH 135/166] fix(score): expose real publisher termination boundaries around publication --- apps/desktop/core/src/score_storage.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/core/src/score_storage.rs b/apps/desktop/core/src/score_storage.rs index cf946b0e3..31da67d7c 100644 --- a/apps/desktop/core/src/score_storage.rs +++ b/apps/desktop/core/src/score_storage.rs @@ -598,11 +598,14 @@ where // write ownership while making the completed stage publishable. drop(stage_file); + crate::score_publication::wait_at_fault_checkpoint("after-stage-sync-before-link"); + if fs::hard_link(&stage, &destination).is_err() { let _ = remove_owned_stage(&stage, expected_stage); return Err(SCORE_ATTACH_ERROR.to_string()); } + crate::score_publication::wait_at_fault_checkpoint("after-link-before-stage-retirement"); after_link(&stage, &destination); // A same-length regular-file replacement is not the object we staged. From ffb426fe1e7ce75f5eff7a8ae7953142dd1ff0fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:19:30 +0900 Subject: [PATCH 136/166] docs(score): trace real publisher termination across publication boundaries --- .../score-attachment-fault-recovery.md | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index 60654312a..3af4cbbc3 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -6,9 +6,9 @@ Related owner: Project Persistence #970 ## Problem -Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults that occur before publication as well as faults that leave an indeterminate reserved stage. +Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults before publication, during publication alias transition, and after publication but before successful return. -Five cases are owned here. An interrupted fixed-buffer copy may leave incomplete temporary bytes. A native permission failure can make a reserved stage unreadable at restart. A kernel-enforced file-size limit can make the actual publication writer fail after it has written only part of the stage. Actual filesystem capacity exhaustion can also make that writer fail after a real `ENOSPC`, which must be distinguished from an artificial file-size limit. Finally, the real public publisher can be terminated after the destination has been published and the stage alias retired but before the successful-return metadata barrier. None of these states may invent a durable project reference, delete buyer bytes on pathname authority, or misreport an interrupted publisher as a successful attachment. +Current native evidence covers interrupted copy, Unix permission denial, Windows ACL denial, kernel-enforced write failure, actual macOS `ENOSPC`, and real-public-publisher process termination at three deterministic publication boundaries. None of these states may invent a durable project reference, delete buyer bytes on pathname authority, or misreport an interrupted publisher as a successful attachment. ## Repair lineage @@ -20,34 +20,42 @@ Windows ACL denial is covered separately by `score-attachment-windows-acl-recove `64466c782ae244799afad76c3e02fe20e7a5d6ae` added `score_pdf_capacity_exhaustion_recovery.rs`. On macOS it creates a fixed-size 16 MiB HFS+ disk image, writes and synchronizes filler blocks until the OS reports raw `ENOSPC` (`28`), releases only a 512 KiB reserve, and then sends a 1 MiB PDF-shaped host source through the public Score Storage publisher. This preserves the 25 MiB production ceiling while making the destination filesystem genuinely too small for the selected score. -`16f559cf38d30d5548426262cd4614459fc3e8ea` added the new regression to the explicit owner workflow. That exact head produced a real hosted failure in `score-storage-native` run `35524498229`, macOS job `106114188950`, before the product boundary was reached: current macOS `hdiutil create` rejected the fixture's `-format UDRW` combination with `-fs` unless a source folder/device was supplied. This was a fixture-provisioning RCA, not a Score Storage behavioral failure. `b6922153583543cf898e8bec0f3941408f551003` also made each capacity-filler block call `sync_data()` so delayed allocation cannot turn the test into a page-cache-only approximation. `3c64fb31bd09326b10bb16313cc42edbb0a19e85` removed the invalid image-format argument. Exact head `3c64fb31bd09326b10bb16313cc42edbb0a19e85` then completed owner run `35524589689` successfully on macOS 15 job `106114428123` and Windows Server 2025 job `106114428002`; the macOS lane executes the ENOSPC behavior and the Windows target is platform-gated while the rest of the native owner suite still runs. +`16f559cf38d30d5548426262cd4614459fc3e8ea` added the capacity regression to the explicit owner workflow. That exact head produced a real hosted failure in `score-storage-native` run `35524498229`, macOS job `106114188950`, before the product boundary was reached: current macOS `hdiutil create` rejected the fixture's `-format UDRW` combination with `-fs` unless a source folder/device was supplied. This was a fixture-provisioning RCA, not a Score Storage behavioral failure. `b6922153583543cf898e8bec0f3941408f551003` made each capacity-filler block call `sync_data()` so delayed allocation cannot turn the test into a page-cache-only approximation. `3c64fb31bd09326b10bb16313cc42edbb0a19e85` removed the invalid image-format argument. Exact head `3c64fb31bd09326b10bb16313cc42edbb0a19e85` then completed owner run `35524589689` successfully on both desktop lanes. -`e5323d3eec9c5eb2e89ac688930a84bebd1ce430` defines the owner-only `score-storage-fault-injection` Cargo feature. `814272038c8b1d522a16ef4a5d707ff9a8cc474d` adds `score_pdf_process_termination_recovery.rs`, whose child process invokes the real public `publish_score_pdf_attachment` API rather than constructing a reserved stage itself. `a23313b060d14daf691b7c3489be355c4adb6e04` makes that regression an explicit owner-native workflow target. At that test-only head no product checkpoint existed, so the child would return before exposing the requested `before-metadata-barrier` readiness marker; this is the behavioral source-level RED. Its owner run `35534584400` did not settle to a terminal hosted verdict before the repair descendant existed, so no hosted RED is claimed. `5a571e8c018f503ddbd0633d00fd4554d02a9da0` adds the deterministic checkpoint immediately before the platform successful-return metadata barrier. The checkpoint is compiled only when the owner-only feature is enabled and activates only for the exact fault-injection environment. +`e5323d3eec9c5eb2e89ac688930a84bebd1ce430` defines the owner-only `score-storage-fault-injection` Cargo feature. `814272038c8b1d522a16ef4a5d707ff9a8cc474d` adds the first `score_pdf_process_termination_recovery.rs`, whose child invokes real public `publish_score_pdf_attachment` rather than constructing filesystem state itself. `a23313b060d14daf691b7c3489be355c4adb6e04` makes that regression an explicit owner-native workflow target. At that test-only head no checkpoint existed, so the child would return before exposing `before-metadata-barrier`; this is the behavioral source-level RED. Its owner run `35534584400` did not settle to terminal failure before a repair descendant existed, so no hosted RED is claimed. `5a571e8c018f503ddbd0633d00fd4554d02a9da0` adds the deterministic checkpoint immediately before the platform successful-return metadata barrier. -Review of that repair found a second release-safety requirement: a deliberate indefinite wait must never become reachable in a shipped build merely because a feature is accidentally enabled. `61f94a4b076d83109703aedde51f73f1eb9e47e3` therefore adds a compile-time fail-closed guard for `score-storage-fault-injection` whenever `debug_assertions` are absent. `03b6b3c14f65ba80dcfeb0a5180dcc8b4c9d8543` extends the owner workflow with two release-inventory checks: the normal desktop dependency feature graph must not contain `score-storage-fault-injection`, and a release-mode core build that explicitly enables the owner-only feature must be rejected by the compile guard. The normal owner-native termination regression continues to enable the feature only in its debug test command. +Review of that repair identified a release-safety requirement: a deliberate indefinite wait must never become reachable in a shipped build merely because a feature is accidentally enabled. `61f94a4b076d83109703aedde51f73f1eb9e47e3` adds a compile-time fail-closed guard for `score-storage-fault-injection` whenever `debug_assertions` are absent. `03b6b3c14f65ba80dcfeb0a5180dcc8b4c9d8543` adds owner checks that the normal Tauri desktop dependency feature graph excludes the feature and that an explicit release-mode core build with the feature is rejected. Exact `0be5683c2a7ea17d89e0b5deeb88c5e0bea0134d` completed owner run `35534785802` successfully on macOS 15 job `106141747352` and Windows Server 2025 job `106141747629`, including both feature-exclusion evidence and the pre-metadata-barrier termination regression. + +That still left two earlier production boundaries represented only by manually constructed filesystem fixtures. `8f3186938c7754a24cbfdd31fbdcfa81a1415eea` extends the real-public-publisher regression with required `after-stage-sync-before-link` and `after-link-before-stage-retirement` checkpoints. At that test-only head those checkpoints did not exist, so both new cases are deterministic behavioral source-level REDs. `1a381460d0ee9a219a1bbc871b9db3b078accb41` makes the existing owner-only checkpoint helper crate-visible without changing default-build behavior. `1f1377c219f885996dccdd81168ae23529fe4a55` places the two new checkpoints in the lower publication owner: one after the synchronized stage handle is closed and before `hard_link`, the other immediately after `hard_link` and before destination attestation/stage retirement. Default builds still execute a no-op helper; release builds still fail closed if the owner-test feature is enabled. ## Fault semantics -The interrupted-copy fixture leaves only `%PD` in an exact reserved `.score-.stage` name. This represents a process disappearing before the descriptor-bounded copy completes. Restart inventory retires that owned temporary residue and returns no published receipt. +The interrupted-copy fixture leaves only `%PD` in an exact reserved `.score-.stage` name. Restart inventory retires that owned temporary residue and returns no published receipt. On Unix, the permission fixture makes an exact reserved stage unreadable before restart inventory. Recovery returns an error and preserves the stage. It does not infer that unreadable bytes are disposable merely because their pathname is in the reserved namespace. -The kernel write-failure regression exercises another boundary. The parent creates a 128 KiB PDF-shaped source, then starts the current integration-test binary in a child process with a 512-byte file-size limit. The production publisher opens the real private stage and encounters the kernel write failure while copying. The child must return the generic attachment error rather than crash or report success. After the child exits, the parent verifies that no `.pdf` exists, the writer-owned partial stage was retired, the source length is unchanged, and fresh restart inventory is empty. +The kernel write-failure regression creates a 128 KiB PDF-shaped source, then starts the current integration-test binary in a child process with a 512-byte file-size limit. The production publisher encounters the kernel write failure while copying. It must return the generic attachment error, publish no destination, retire the owned partial stage, preserve the source and leave fresh inventory empty. + +The capacity-exhaustion regression is deliberately different from `RLIMIT_FSIZE`. The selected 1 MiB source lives outside the isolated image. The test fills and synchronizes the fixed-size image until real `ENOSPC`, removes only a 512 KiB reserve, and invokes the public publisher. Publication must fail before destination truth is created; the owned partial stage must be retired, the source must remain unchanged and restart inventory must be empty. + +The real-publisher termination suite now owns three checkpoints under the same public API and workspace lease: -The capacity-exhaustion regression is deliberately different from `RLIMIT_FSIZE`. The selected 1 MiB source lives outside the isolated image. The test fills and synchronizes the fixed-size image until a real `ENOSPC` is observed, removes only a 512 KiB reserve, and invokes the public publisher. Publication must fail before destination truth is created; the owned partial stage must be retired, the source must remain 1 MiB, and restart inventory must remain empty. The source size is a fault-fixture choice, not a reduction of the 25 MiB product ceiling. +- `after-stage-sync-before-link`: synchronized stage exists, destination does not. After process termination, fresh recovery must retire the abandoned stage and return no published receipt. +- `after-link-before-stage-retirement`: stage and destination both exist with the same buyer bytes. After termination, fresh recovery must retain the destination, retire only the equal-content temporary alias and return exactly one path-free receipt. +- `before-metadata-barrier`: stage has been retired and destination has passed final lower-level identity attestation, while the public wrapper still holds the workspace lease. After termination, fresh inventory must retain the destination and return exactly one receipt. -The process-termination regression exercises the opposite side of publication. With the owner-only feature enabled, the real public publisher reaches a deterministic checkpoint only after the lower publisher has synchronized and published the destination, retired the temporary stage alias and completed final destination identity attestation, but before the platform successful-return metadata barrier runs. The checkpoint is inside the Score Storage workspace lease. The parent verifies `stage absent + destination present + live inventory rejected by lease`, terminates the publisher process, and then requires a fresh inventory call to recover one path-free published-object receipt while preserving the destination bytes. This proves recovery semantics for a process killed at that exact owner boundary; it does not prove sudden-power-loss durability or that project metadata had accepted the attachment. +At every checkpoint the parent first requires live inventory to fail because the child still owns `.score-storage.lock`; this proves the test has not escaped the actual owner transaction before termination. ## Security Notes **Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. -**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content, and native deletion authority. The process-termination checkpoint is owner-test instrumentation behind an explicit Cargo feature and exact environment selector; it does not add a runtime command, network surface or product mutation authority. Release builds fail to compile if the feature is enabled, and the desktop dependency feature graph is checked separately so the shipped manifest cannot silently opt into it. +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content and native deletion authority. Process-termination checkpoints are owner-test instrumentation behind an explicit Cargo feature and exact environment selector; they add no runtime command, network surface or product mutation authority. Release builds fail to compile if the feature is enabled, and the desktop dependency feature graph is checked separately so the shipped manifest cannot silently opt into it. -**Safe failure.** Incomplete stage-only bytes are removed only from the reserved temporary namespace and never surfaced as a published score. Indeterminate or unreadable state is preserved and blocks recovery. A publication write or capacity failure cannot create destination truth because hard-link publication occurs only after the stage copy and `sync_all()` succeed. A process killed after stage retirement but before the successful-return barrier leaves published object truth for restart inventory, not a fabricated project reference or a guessed delete. +**Safe failure.** Incomplete or stage-only state never becomes destination truth. Equal stage+destination state retires only the temporary alias after validated content equality. A process killed after stage retirement but before the successful-return barrier leaves published object truth for restart inventory, not a fabricated Project Persistence reference or guessed delete. -**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, a Unix kernel-enforced file-size write failure, macOS fixed-filesystem capacity exhaustion that reaches real `ENOSPC` through the public publication path, and owner-native process termination of the real public publisher immediately before the successful-return metadata barrier. The latest process-termination and release-feature exclusion source has not yet been promoted to hosted GREEN in this document. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion, or Windows directory-entry successful-return durability. It also does not convert the isolated 1 MiB capacity fixture into a smaller product payload limit. +**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, Unix kernel-enforced file-size write failure, macOS fixed-filesystem `ENOSPC`, and real-public-publisher process termination at the three publication checkpoints above. Exact `0be5683c...` is historical hosted GREEN for the pre-metadata-barrier case and feature exclusion; the newer three-boundary source requires fresh exact-head owner settlement before it is called hosted GREEN. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion or Windows directory-entry successful-return durability. ## Next buyer gap -After the current exact-head owner workflow settles, process-termination evidence should move earlier in the actual publisher lifecycle so stage-synchronized/pre-link and post-link/pre-stage-retirement boundaries are driven by the real publisher rather than hand-built filesystem fixtures. A packaged desktop-executable cancellation path is still separate from an owner-native integration-test child. Sudden-power-loss/directory-entry durability follows after that. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race also remains an explicit storage-authority gap. +After the current three-boundary exact head settles, remaining process-lifecycle evidence is the packaged desktop executable rather than the owner-native integration-test child. Sudden-power-loss/directory-entry durability follows after that. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race and coexistence with an older unleased BandScope build remain explicit storage-authority gaps. From 81096ccb0cbe22c593db9d4cff2cd19877662453 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:34:38 +0900 Subject: [PATCH 137/166] test(score): require desktop executable termination recovery --- .../score_storage_executable_termination.rs | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/score_storage_executable_termination.rs diff --git a/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs b/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs new file mode 100644 index 000000000..572d22694 --- /dev/null +++ b/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs @@ -0,0 +1,194 @@ +#![cfg(feature = "score-storage-fault-injection")] + +use bandscope_desktop_core::inventory_published_score_pdf_receipts; +use std::{ + path::{Path, PathBuf}, + process::{Child, Command}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const CHILD_MODE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_ROOT"; +const SOURCE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SOURCE"; +const SCORE_ID_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SCORE_ID"; +const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; +const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; +const AFTER_STAGE_SYNC_BEFORE_LINK: &str = "after-stage-sync-before-link"; +const AFTER_LINK_BEFORE_STAGE_RETIREMENT: &str = "after-link-before-stage-retirement"; +const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; +const EXPECTED: &[u8] = b"%PDF-1.7\ndesktop executable termination fixture"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +fn fixture(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let base = unique_test_dir(name); + let scores_root = base.join("scores"); + let source = base.join("selected.pdf"); + let marker = base.join("desktop-publisher-checkpoint-ready"); + std::fs::create_dir_all(&scores_root).expect("score root should be created"); + std::fs::write(&source, EXPECTED).expect("score fixture should be written"); + (base, scores_root, source, marker) +} + +fn desktop_binary() -> &'static str { + env!("CARGO_BIN_EXE_bandscope-desktop") +} + +fn spawn_desktop_publisher( + checkpoint: &str, + scores_root: &Path, + source: &Path, + score_id: &str, + marker: &Path, +) -> Child { + Command::new(desktop_binary()) + .env(CHILD_MODE_ENV, "1") + .env(ROOT_ENV, scores_root) + .env(SOURCE_ENV, source) + .env(SCORE_ID_ENV, score_id) + .env(CHECKPOINT_ENV, checkpoint) + .env(MARKER_ENV, marker) + .spawn() + .expect("desktop publisher child should start") +} + +fn wait_for_checkpoint(child: &mut Child, marker: &Path, checkpoint: &str) { + let deadline = Instant::now() + Duration::from_secs(20); + while !marker.exists() && Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .expect("desktop publisher child status should be observable") + { + panic!("desktop publisher child exited before {checkpoint}: {status}"); + } + thread::sleep(Duration::from_millis(20)); + } + if !marker.exists() { + let _ = child.kill(); + let _ = child.wait(); + panic!("desktop publisher child did not expose {checkpoint}"); + } + assert_eq!( + std::fs::read_to_string(marker).expect("checkpoint marker should be readable"), + checkpoint, + "the desktop child must expose the requested publication boundary" + ); +} + +fn terminate_and_reap(child: &mut Child) { + child + .kill() + .expect("desktop publisher child should terminate at the requested boundary"); + let status = child.wait().expect("desktop publisher child should be reaped"); + assert!( + !status.success(), + "the desktop publisher must end by process termination, not successful return" + ); +} + +fn assert_live_lease(scores_root: &Path) { + assert!( + inventory_published_score_pdf_receipts(scores_root).is_err(), + "the running desktop executable must still own the Score Storage lease" + ); +} + +#[test] +fn desktop_executable_termination_after_stage_sync_recovers_stage_only() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a101"; + let (base, scores_root, source, marker) = fixture("desktop-stage-sync-termination"); + let mut child = spawn_desktop_publisher( + AFTER_STAGE_SYNC_BEFORE_LINK, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, AFTER_STAGE_SYNC_BEFORE_LINK); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(stage.exists(), "the synchronized stage must exist before publication"); + assert!(!destination.exists(), "destination truth must not exist before the link"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retire the abandoned stage-only state"); + assert!(receipts.is_empty()); + assert!(!stage.exists(), "restart recovery should retire the abandoned stage"); + assert!(!destination.exists(), "restart recovery must not invent destination truth"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn desktop_executable_termination_after_link_recovers_equal_aliases() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a102"; + let (base, scores_root, source, marker) = fixture("desktop-post-link-termination"); + let mut child = spawn_desktop_publisher( + AFTER_LINK_BEFORE_STAGE_RETIREMENT, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, AFTER_LINK_BEFORE_STAGE_RETIREMENT); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(stage.exists(), "the temporary alias must exist after link creation"); + assert!(destination.exists(), "the destination must exist after publication link creation"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retain destination truth and retire the equal stage alias"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), score_id); + assert!(!stage.exists(), "restart recovery should retire only the temporary alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive"), EXPECTED); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn desktop_executable_termination_before_metadata_barrier_is_recoverable() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a103"; + let (base, scores_root, source, marker) = fixture("desktop-pre-barrier-termination"); + let mut child = spawn_desktop_publisher( + BEFORE_METADATA_BARRIER, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, BEFORE_METADATA_BARRIER); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(!stage.exists(), "the lower publisher must retire the stage before the metadata barrier"); + assert!(destination.exists(), "the destination must already be published before the barrier"); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retain the published object after desktop termination"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), score_id); + assert!(!stage.exists(), "restart recovery must not recreate a retired stage alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive recovery"), EXPECTED); + let _ = std::fs::remove_dir_all(base); +} From d2cf4e0a5f3e8ca319169a3c303fb97feb3fff46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:34:56 +0900 Subject: [PATCH 138/166] test(score): execute desktop termination owner contract --- .github/workflows/score-storage-native.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index c018e1156..ed97cece8 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -19,6 +19,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -46,6 +47,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -136,3 +138,9 @@ jobs: --manifest-path apps/desktop/core/Cargo.toml --features score-storage-fault-injection --test score_pdf_process_termination_recovery + - name: Run desktop-executable process-termination recovery regression + run: >- + cargo +1.97.1 test + --manifest-path apps/desktop/src-tauri/Cargo.toml + --features score-storage-fault-injection + --test score_storage_executable_termination From 8170def44fea860c0ab0b82235f6e15c821671e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:35:03 +0900 Subject: [PATCH 139/166] fix(score): gate desktop termination harness behind owner feature --- apps/desktop/src-tauri/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bcafbab44..dd2785dd7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ uuid = { version = "1", features = ["v4"] } [features] default = [] +score-storage-fault-injection = ["bandscope-desktop-core/score-storage-fault-injection"] [package.metadata.opencode.coverage] # Baseline measured by the central Rust coverage evidence gate on PR #527. From c63002d2f343407886425172f9c652564bf0edc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:36:10 +0900 Subject: [PATCH 140/166] fix(score): add owner-only desktop package fault harness --- .../src/bin/score-storage-fault-harness.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs diff --git a/apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs b/apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs new file mode 100644 index 000000000..9fc33f67a --- /dev/null +++ b/apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs @@ -0,0 +1,25 @@ +use bandscope_desktop_core::publish_score_pdf_attachment; +use std::path::PathBuf; + +const CHILD_MODE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_ROOT"; +const SOURCE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SOURCE"; +const SCORE_ID_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SCORE_ID"; + +fn required_path(name: &str) -> PathBuf { + PathBuf::from(std::env::var_os(name).unwrap_or_else(|| panic!("missing owner-test env {name}"))) +} + +fn main() { + if std::env::var_os(CHILD_MODE_ENV).is_none() { + panic!("score-storage fault harness is owner-test-only"); + } + + let scores_root = required_path(ROOT_ENV); + let source = required_path(SOURCE_ENV); + let score_id = std::env::var(SCORE_ID_ENV).expect("score id should be supplied by the owner test"); + + publish_score_pdf_attachment(&source, &scores_root, &score_id) + .expect("desktop-package publisher should reach the requested checkpoint before returning"); + panic!("desktop-package publisher unexpectedly returned instead of waiting at the checkpoint"); +} From a832db1b3f5c1c5dfc4ff69fdfa20a0cffb59ada Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:36:17 +0900 Subject: [PATCH 141/166] fix(score): keep fault harness out of default desktop targets --- apps/desktop/src-tauri/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index dd2785dd7..8e50de093 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -3,6 +3,11 @@ name = "bandscope-desktop" version = "0.1.0" edition = "2021" +[[bin]] +name = "score-storage-fault-harness" +path = "src/bin/score-storage-fault-harness.rs" +required-features = ["score-storage-fault-injection"] + [build-dependencies] tauri-build = { version = "2", default-features = false, features = [] } From 6080fc292e8b4b1f5e9b0481c78e5cb040199084 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:36:47 +0900 Subject: [PATCH 142/166] fix(score): bind termination regression to owner harness binary --- .../score_storage_executable_termination.rs | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs b/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs index 572d22694..d5c8bfe9d 100644 --- a/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs +++ b/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs @@ -17,7 +17,7 @@ const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; const AFTER_STAGE_SYNC_BEFORE_LINK: &str = "after-stage-sync-before-link"; const AFTER_LINK_BEFORE_STAGE_RETIREMENT: &str = "after-link-before-stage-retirement"; const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; -const EXPECTED: &[u8] = b"%PDF-1.7\ndesktop executable termination fixture"; +const EXPECTED: &[u8] = b"%PDF-1.7\ndesktop package executable termination fixture"; fn unique_test_dir(name: &str) -> PathBuf { let suffix = SystemTime::now() @@ -31,24 +31,24 @@ fn fixture(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { let base = unique_test_dir(name); let scores_root = base.join("scores"); let source = base.join("selected.pdf"); - let marker = base.join("desktop-publisher-checkpoint-ready"); + let marker = base.join("desktop-package-publisher-checkpoint-ready"); std::fs::create_dir_all(&scores_root).expect("score root should be created"); std::fs::write(&source, EXPECTED).expect("score fixture should be written"); (base, scores_root, source, marker) } -fn desktop_binary() -> &'static str { - env!("CARGO_BIN_EXE_bandscope-desktop") +fn package_fault_harness() -> &'static str { + env!("CARGO_BIN_EXE_score-storage-fault-harness") } -fn spawn_desktop_publisher( +fn spawn_desktop_package_publisher( checkpoint: &str, scores_root: &Path, source: &Path, score_id: &str, marker: &Path, ) -> Child { - Command::new(desktop_binary()) + Command::new(package_fault_harness()) .env(CHILD_MODE_ENV, "1") .env(ROOT_ENV, scores_root) .env(SOURCE_ENV, source) @@ -56,7 +56,7 @@ fn spawn_desktop_publisher( .env(CHECKPOINT_ENV, checkpoint) .env(MARKER_ENV, marker) .spawn() - .expect("desktop publisher child should start") + .expect("desktop-package publisher child should start") } fn wait_for_checkpoint(child: &mut Child, marker: &Path, checkpoint: &str) { @@ -64,47 +64,47 @@ fn wait_for_checkpoint(child: &mut Child, marker: &Path, checkpoint: &str) { while !marker.exists() && Instant::now() < deadline { if let Some(status) = child .try_wait() - .expect("desktop publisher child status should be observable") + .expect("desktop-package publisher child status should be observable") { - panic!("desktop publisher child exited before {checkpoint}: {status}"); + panic!("desktop-package publisher child exited before {checkpoint}: {status}"); } thread::sleep(Duration::from_millis(20)); } if !marker.exists() { let _ = child.kill(); let _ = child.wait(); - panic!("desktop publisher child did not expose {checkpoint}"); + panic!("desktop-package publisher child did not expose {checkpoint}"); } assert_eq!( std::fs::read_to_string(marker).expect("checkpoint marker should be readable"), checkpoint, - "the desktop child must expose the requested publication boundary" + "the desktop-package child must expose the requested publication boundary" ); } fn terminate_and_reap(child: &mut Child) { child .kill() - .expect("desktop publisher child should terminate at the requested boundary"); - let status = child.wait().expect("desktop publisher child should be reaped"); + .expect("desktop-package publisher child should terminate at the requested boundary"); + let status = child.wait().expect("desktop-package publisher child should be reaped"); assert!( !status.success(), - "the desktop publisher must end by process termination, not successful return" + "the desktop-package publisher must end by process termination, not successful return" ); } fn assert_live_lease(scores_root: &Path) { assert!( inventory_published_score_pdf_receipts(scores_root).is_err(), - "the running desktop executable must still own the Score Storage lease" + "the running desktop-package executable must still own the Score Storage lease" ); } #[test] -fn desktop_executable_termination_after_stage_sync_recovers_stage_only() { +fn desktop_package_executable_termination_after_stage_sync_recovers_stage_only() { let score_id = "21b419e4-f604-4c04-b57e-8c589751a101"; - let (base, scores_root, source, marker) = fixture("desktop-stage-sync-termination"); - let mut child = spawn_desktop_publisher( + let (base, scores_root, source, marker) = fixture("desktop-package-stage-sync-termination"); + let mut child = spawn_desktop_package_publisher( AFTER_STAGE_SYNC_BEFORE_LINK, &scores_root, &source, @@ -131,10 +131,10 @@ fn desktop_executable_termination_after_stage_sync_recovers_stage_only() { } #[test] -fn desktop_executable_termination_after_link_recovers_equal_aliases() { +fn desktop_package_executable_termination_after_link_recovers_equal_aliases() { let score_id = "21b419e4-f604-4c04-b57e-8c589751a102"; - let (base, scores_root, source, marker) = fixture("desktop-post-link-termination"); - let mut child = spawn_desktop_publisher( + let (base, scores_root, source, marker) = fixture("desktop-package-post-link-termination"); + let mut child = spawn_desktop_package_publisher( AFTER_LINK_BEFORE_STAGE_RETIREMENT, &scores_root, &source, @@ -163,10 +163,10 @@ fn desktop_executable_termination_after_link_recovers_equal_aliases() { } #[test] -fn desktop_executable_termination_before_metadata_barrier_is_recoverable() { +fn desktop_package_executable_termination_before_metadata_barrier_is_recoverable() { let score_id = "21b419e4-f604-4c04-b57e-8c589751a103"; - let (base, scores_root, source, marker) = fixture("desktop-pre-barrier-termination"); - let mut child = spawn_desktop_publisher( + let (base, scores_root, source, marker) = fixture("desktop-package-pre-barrier-termination"); + let mut child = spawn_desktop_package_publisher( BEFORE_METADATA_BARRIER, &scores_root, &source, @@ -185,7 +185,7 @@ fn desktop_executable_termination_before_metadata_barrier_is_recoverable() { terminate_and_reap(&mut child); let receipts = inventory_published_score_pdf_receipts(&scores_root) - .expect("restart recovery should retain the published object after desktop termination"); + .expect("restart recovery should retain the published object after desktop-package termination"); assert_eq!(receipts.len(), 1); assert_eq!(receipts[0].score_id(), score_id); assert!(!stage.exists(), "restart recovery must not recreate a retired stage alias"); From e66decc01676e1864beeb87762b713441d9bf917 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:37:07 +0900 Subject: [PATCH 143/166] test(score): own desktop-package fault harness in native gate --- .github/workflows/score-storage-native.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index ed97cece8..240625e7c 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -19,6 +19,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" @@ -47,6 +48,7 @@ on: - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" + - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" @@ -115,6 +117,14 @@ jobs: echo "::error::A release build accepted the owner-only fault-injection feature." exit 1 fi + if cargo +1.97.1 check \ + --release \ + --manifest-path apps/desktop/src-tauri/Cargo.toml \ + --features score-storage-fault-injection \ + --bin score-storage-fault-harness; then + echo "::error::A release desktop package accepted the owner-only fault harness." + exit 1 + fi - name: Run Score Storage publication, retention, interruption, restart, inventory, receipt, durability, fault-recovery, write-failure, capacity-exhaustion, Windows ACL, Unix cleanup, and wiring regressions run: >- cargo +1.97.1 test @@ -138,7 +148,7 @@ jobs: --manifest-path apps/desktop/core/Cargo.toml --features score-storage-fault-injection --test score_pdf_process_termination_recovery - - name: Run desktop-executable process-termination recovery regression + - name: Run desktop-package executable process-termination recovery regression run: >- cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml From 0fd88675ab1fa6b6a06902ec6b7b1db5d0024286 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:41:07 +0900 Subject: [PATCH 144/166] fix(ci): select owner harness binary for desktop termination test --- .github/workflows/score-storage-native.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 240625e7c..62da22783 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -153,4 +153,5 @@ jobs: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --features score-storage-fault-injection + --bin score-storage-fault-harness --test score_storage_executable_termination From 8dbbbbc923767562a3413d675d69bc69e72f7bbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:44:56 +0900 Subject: [PATCH 145/166] fix(score): drive package harness from core owner regression --- ...df_desktop_package_termination_recovery.rs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_desktop_package_termination_recovery.rs diff --git a/apps/desktop/core/tests/score_pdf_desktop_package_termination_recovery.rs b/apps/desktop/core/tests/score_pdf_desktop_package_termination_recovery.rs new file mode 100644 index 000000000..cf99154dc --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_desktop_package_termination_recovery.rs @@ -0,0 +1,198 @@ +#![cfg(feature = "score-storage-fault-injection")] + +use bandscope_desktop_core::inventory_published_score_pdf_receipts; +use std::{ + path::{Path, PathBuf}, + process::{Child, Command}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const HARNESS_BIN_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_HARNESS_BIN"; +const CHILD_MODE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_CHILD"; +const ROOT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_ROOT"; +const SOURCE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SOURCE"; +const SCORE_ID_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SCORE_ID"; +const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; +const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; +const AFTER_STAGE_SYNC_BEFORE_LINK: &str = "after-stage-sync-before-link"; +const AFTER_LINK_BEFORE_STAGE_RETIREMENT: &str = "after-link-before-stage-retirement"; +const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; +const EXPECTED: &[u8] = b"%PDF-1.7\ndesktop package executable termination fixture"; + +fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) +} + +fn fixture(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let base = unique_test_dir(name); + let scores_root = base.join("scores"); + let source = base.join("selected.pdf"); + let marker = base.join("desktop-package-publisher-checkpoint-ready"); + std::fs::create_dir_all(&scores_root).expect("score root should be created"); + std::fs::write(&source, EXPECTED).expect("score fixture should be written"); + (base, scores_root, source, marker) +} + +fn package_fault_harness() -> PathBuf { + PathBuf::from( + std::env::var_os(HARNESS_BIN_ENV) + .expect("owner workflow must provide the built desktop-package fault harness"), + ) +} + +fn spawn_desktop_package_publisher( + checkpoint: &str, + scores_root: &Path, + source: &Path, + score_id: &str, + marker: &Path, +) -> Child { + Command::new(package_fault_harness()) + .env(CHILD_MODE_ENV, "1") + .env(ROOT_ENV, scores_root) + .env(SOURCE_ENV, source) + .env(SCORE_ID_ENV, score_id) + .env(CHECKPOINT_ENV, checkpoint) + .env(MARKER_ENV, marker) + .spawn() + .expect("desktop-package publisher child should start") +} + +fn wait_for_checkpoint(child: &mut Child, marker: &Path, checkpoint: &str) { + let deadline = Instant::now() + Duration::from_secs(20); + while !marker.exists() && Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .expect("desktop-package publisher child status should be observable") + { + panic!("desktop-package publisher child exited before {checkpoint}: {status}"); + } + thread::sleep(Duration::from_millis(20)); + } + if !marker.exists() { + let _ = child.kill(); + let _ = child.wait(); + panic!("desktop-package publisher child did not expose {checkpoint}"); + } + assert_eq!( + std::fs::read_to_string(marker).expect("checkpoint marker should be readable"), + checkpoint, + "the desktop-package child must expose the requested publication boundary" + ); +} + +fn terminate_and_reap(child: &mut Child) { + child + .kill() + .expect("desktop-package publisher child should terminate at the requested boundary"); + let status = child.wait().expect("desktop-package publisher child should be reaped"); + assert!( + !status.success(), + "the desktop-package publisher must end by process termination, not successful return" + ); +} + +fn assert_live_lease(scores_root: &Path) { + assert!( + inventory_published_score_pdf_receipts(scores_root).is_err(), + "the running desktop-package executable must still own the Score Storage lease" + ); +} + +#[test] +fn desktop_package_executable_termination_after_stage_sync_recovers_stage_only() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a101"; + let (base, scores_root, source, marker) = fixture("desktop-package-stage-sync-termination"); + let mut child = spawn_desktop_package_publisher( + AFTER_STAGE_SYNC_BEFORE_LINK, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, AFTER_STAGE_SYNC_BEFORE_LINK); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(stage.exists(), "the synchronized stage must exist before publication"); + assert!(!destination.exists(), "destination truth must not exist before the link"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retire the abandoned stage-only state"); + assert!(receipts.is_empty()); + assert!(!stage.exists(), "restart recovery should retire the abandoned stage"); + assert!(!destination.exists(), "restart recovery must not invent destination truth"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn desktop_package_executable_termination_after_link_recovers_equal_aliases() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a102"; + let (base, scores_root, source, marker) = fixture("desktop-package-post-link-termination"); + let mut child = spawn_desktop_package_publisher( + AFTER_LINK_BEFORE_STAGE_RETIREMENT, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, AFTER_LINK_BEFORE_STAGE_RETIREMENT); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(stage.exists(), "the temporary alias must exist after link creation"); + assert!(destination.exists(), "the destination must exist after publication link creation"); + assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retain destination truth and retire the equal stage alias"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), score_id); + assert!(!stage.exists(), "restart recovery should retire only the temporary alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive"), EXPECTED); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn desktop_package_executable_termination_before_metadata_barrier_is_recoverable() { + let score_id = "21b419e4-f604-4c04-b57e-8c589751a103"; + let (base, scores_root, source, marker) = fixture("desktop-package-pre-barrier-termination"); + let mut child = spawn_desktop_package_publisher( + BEFORE_METADATA_BARRIER, + &scores_root, + &source, + score_id, + &marker, + ); + wait_for_checkpoint(&mut child, &marker, BEFORE_METADATA_BARRIER); + + let stage = scores_root.join(format!(".score-{score_id}.stage")); + let destination = scores_root.join(format!("{score_id}.pdf")); + assert!(!stage.exists(), "the lower publisher must retire the stage before the metadata barrier"); + assert!(destination.exists(), "the destination must already be published before the barrier"); + assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); + assert_live_lease(&scores_root); + + terminate_and_reap(&mut child); + + let receipts = inventory_published_score_pdf_receipts(&scores_root) + .expect("restart recovery should retain the published object after desktop-package termination"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].score_id(), score_id); + assert!(!stage.exists(), "restart recovery must not recreate a retired stage alias"); + assert_eq!(std::fs::read(&destination).expect("destination should survive recovery"), EXPECTED); + let _ = std::fs::remove_dir_all(base); +} From 949b62d024aa21b7c0340562d507ec5593481c3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:45:13 +0900 Subject: [PATCH 146/166] fix(score): keep package harness regression in core owner lane --- .../score_storage_executable_termination.rs | 194 ------------------ 1 file changed, 194 deletions(-) delete mode 100644 apps/desktop/src-tauri/tests/score_storage_executable_termination.rs diff --git a/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs b/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs deleted file mode 100644 index d5c8bfe9d..000000000 --- a/apps/desktop/src-tauri/tests/score_storage_executable_termination.rs +++ /dev/null @@ -1,194 +0,0 @@ -#![cfg(feature = "score-storage-fault-injection")] - -use bandscope_desktop_core::inventory_published_score_pdf_receipts; -use std::{ - path::{Path, PathBuf}, - process::{Child, Command}, - thread, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; - -const CHILD_MODE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_CHILD"; -const ROOT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_ROOT"; -const SOURCE_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SOURCE"; -const SCORE_ID_ENV: &str = "BANDSCOPE_SCORE_STORAGE_EXECUTABLE_FAULT_SCORE_ID"; -const CHECKPOINT_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_CHECKPOINT"; -const MARKER_ENV: &str = "BANDSCOPE_SCORE_STORAGE_FAULT_MARKER"; -const AFTER_STAGE_SYNC_BEFORE_LINK: &str = "after-stage-sync-before-link"; -const AFTER_LINK_BEFORE_STAGE_RETIREMENT: &str = "after-link-before-stage-retirement"; -const BEFORE_METADATA_BARRIER: &str = "before-metadata-barrier"; -const EXPECTED: &[u8] = b"%PDF-1.7\ndesktop package executable termination fixture"; - -fn unique_test_dir(name: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after Unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) -} - -fn fixture(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { - let base = unique_test_dir(name); - let scores_root = base.join("scores"); - let source = base.join("selected.pdf"); - let marker = base.join("desktop-package-publisher-checkpoint-ready"); - std::fs::create_dir_all(&scores_root).expect("score root should be created"); - std::fs::write(&source, EXPECTED).expect("score fixture should be written"); - (base, scores_root, source, marker) -} - -fn package_fault_harness() -> &'static str { - env!("CARGO_BIN_EXE_score-storage-fault-harness") -} - -fn spawn_desktop_package_publisher( - checkpoint: &str, - scores_root: &Path, - source: &Path, - score_id: &str, - marker: &Path, -) -> Child { - Command::new(package_fault_harness()) - .env(CHILD_MODE_ENV, "1") - .env(ROOT_ENV, scores_root) - .env(SOURCE_ENV, source) - .env(SCORE_ID_ENV, score_id) - .env(CHECKPOINT_ENV, checkpoint) - .env(MARKER_ENV, marker) - .spawn() - .expect("desktop-package publisher child should start") -} - -fn wait_for_checkpoint(child: &mut Child, marker: &Path, checkpoint: &str) { - let deadline = Instant::now() + Duration::from_secs(20); - while !marker.exists() && Instant::now() < deadline { - if let Some(status) = child - .try_wait() - .expect("desktop-package publisher child status should be observable") - { - panic!("desktop-package publisher child exited before {checkpoint}: {status}"); - } - thread::sleep(Duration::from_millis(20)); - } - if !marker.exists() { - let _ = child.kill(); - let _ = child.wait(); - panic!("desktop-package publisher child did not expose {checkpoint}"); - } - assert_eq!( - std::fs::read_to_string(marker).expect("checkpoint marker should be readable"), - checkpoint, - "the desktop-package child must expose the requested publication boundary" - ); -} - -fn terminate_and_reap(child: &mut Child) { - child - .kill() - .expect("desktop-package publisher child should terminate at the requested boundary"); - let status = child.wait().expect("desktop-package publisher child should be reaped"); - assert!( - !status.success(), - "the desktop-package publisher must end by process termination, not successful return" - ); -} - -fn assert_live_lease(scores_root: &Path) { - assert!( - inventory_published_score_pdf_receipts(scores_root).is_err(), - "the running desktop-package executable must still own the Score Storage lease" - ); -} - -#[test] -fn desktop_package_executable_termination_after_stage_sync_recovers_stage_only() { - let score_id = "21b419e4-f604-4c04-b57e-8c589751a101"; - let (base, scores_root, source, marker) = fixture("desktop-package-stage-sync-termination"); - let mut child = spawn_desktop_package_publisher( - AFTER_STAGE_SYNC_BEFORE_LINK, - &scores_root, - &source, - score_id, - &marker, - ); - wait_for_checkpoint(&mut child, &marker, AFTER_STAGE_SYNC_BEFORE_LINK); - - let stage = scores_root.join(format!(".score-{score_id}.stage")); - let destination = scores_root.join(format!("{score_id}.pdf")); - assert!(stage.exists(), "the synchronized stage must exist before publication"); - assert!(!destination.exists(), "destination truth must not exist before the link"); - assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); - assert_live_lease(&scores_root); - - terminate_and_reap(&mut child); - - let receipts = inventory_published_score_pdf_receipts(&scores_root) - .expect("restart recovery should retire the abandoned stage-only state"); - assert!(receipts.is_empty()); - assert!(!stage.exists(), "restart recovery should retire the abandoned stage"); - assert!(!destination.exists(), "restart recovery must not invent destination truth"); - let _ = std::fs::remove_dir_all(base); -} - -#[test] -fn desktop_package_executable_termination_after_link_recovers_equal_aliases() { - let score_id = "21b419e4-f604-4c04-b57e-8c589751a102"; - let (base, scores_root, source, marker) = fixture("desktop-package-post-link-termination"); - let mut child = spawn_desktop_package_publisher( - AFTER_LINK_BEFORE_STAGE_RETIREMENT, - &scores_root, - &source, - score_id, - &marker, - ); - wait_for_checkpoint(&mut child, &marker, AFTER_LINK_BEFORE_STAGE_RETIREMENT); - - let stage = scores_root.join(format!(".score-{score_id}.stage")); - let destination = scores_root.join(format!("{score_id}.pdf")); - assert!(stage.exists(), "the temporary alias must exist after link creation"); - assert!(destination.exists(), "the destination must exist after publication link creation"); - assert_eq!(std::fs::read(&stage).expect("stage should be readable"), EXPECTED); - assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); - assert_live_lease(&scores_root); - - terminate_and_reap(&mut child); - - let receipts = inventory_published_score_pdf_receipts(&scores_root) - .expect("restart recovery should retain destination truth and retire the equal stage alias"); - assert_eq!(receipts.len(), 1); - assert_eq!(receipts[0].score_id(), score_id); - assert!(!stage.exists(), "restart recovery should retire only the temporary alias"); - assert_eq!(std::fs::read(&destination).expect("destination should survive"), EXPECTED); - let _ = std::fs::remove_dir_all(base); -} - -#[test] -fn desktop_package_executable_termination_before_metadata_barrier_is_recoverable() { - let score_id = "21b419e4-f604-4c04-b57e-8c589751a103"; - let (base, scores_root, source, marker) = fixture("desktop-package-pre-barrier-termination"); - let mut child = spawn_desktop_package_publisher( - BEFORE_METADATA_BARRIER, - &scores_root, - &source, - score_id, - &marker, - ); - wait_for_checkpoint(&mut child, &marker, BEFORE_METADATA_BARRIER); - - let stage = scores_root.join(format!(".score-{score_id}.stage")); - let destination = scores_root.join(format!("{score_id}.pdf")); - assert!(!stage.exists(), "the lower publisher must retire the stage before the metadata barrier"); - assert!(destination.exists(), "the destination must already be published before the barrier"); - assert_eq!(std::fs::read(&destination).expect("destination should be readable"), EXPECTED); - assert_live_lease(&scores_root); - - terminate_and_reap(&mut child); - - let receipts = inventory_published_score_pdf_receipts(&scores_root) - .expect("restart recovery should retain the published object after desktop-package termination"); - assert_eq!(receipts.len(), 1); - assert_eq!(receipts[0].score_id(), score_id); - assert!(!stage.exists(), "restart recovery must not recreate a retired stage alias"); - assert_eq!(std::fs::read(&destination).expect("destination should survive recovery"), EXPECTED); - let _ = std::fs::remove_dir_all(base); -} From c670904108f60dd7f1f2a0ca5747ecb38b09f8ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:45:38 +0900 Subject: [PATCH 147/166] fix(ci): isolate desktop fault harness from Tauri main target --- .github/workflows/score-storage-native.yml | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 62da22783..9ebdd8cff 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -20,7 +20,6 @@ on: - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -49,7 +48,6 @@ on: - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - - "apps/desktop/src-tauri/tests/score_storage_executable_termination.rs" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -149,9 +147,19 @@ jobs: --features score-storage-fault-injection --test score_pdf_process_termination_recovery - name: Run desktop-package executable process-termination recovery regression - run: >- - cargo +1.97.1 test - --manifest-path apps/desktop/src-tauri/Cargo.toml - --features score-storage-fault-injection - --bin score-storage-fault-harness - --test score_storage_executable_termination + shell: bash + run: | + export CARGO_TARGET_DIR="$RUNNER_TEMP/bandscope-score-fault-harness" + cargo +1.97.1 build \ + --manifest-path apps/desktop/src-tauri/Cargo.toml \ + --features score-storage-fault-injection \ + --bin score-storage-fault-harness + executable_suffix="" + if [[ "$RUNNER_OS" == "Windows" ]]; then + executable_suffix=".exe" + fi + export BANDSCOPE_SCORE_STORAGE_FAULT_HARNESS_BIN="$CARGO_TARGET_DIR/debug/score-storage-fault-harness${executable_suffix}" + cargo +1.97.1 test \ + --manifest-path apps/desktop/core/Cargo.toml \ + --features score-storage-fault-injection \ + --test score_pdf_desktop_package_termination_recovery From 1d0bb7176d3461743d9fe3dea2e9bc94021610a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 05:48:01 +0900 Subject: [PATCH 148/166] docs(score): trace desktop-package termination harness RCA --- .../score-attachment-fault-recovery.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/traceability/score-attachment-fault-recovery.md b/docs/traceability/score-attachment-fault-recovery.md index 3af4cbbc3..705d9ede9 100644 --- a/docs/traceability/score-attachment-fault-recovery.md +++ b/docs/traceability/score-attachment-fault-recovery.md @@ -8,7 +8,7 @@ Related owner: Project Persistence #970 Score Storage publication spans several failure boundaries before a score can be treated as durable object truth: descriptor-bounded copy, staged-file sync, destination publication, stage retirement, and the successful-return metadata barrier. Recovery therefore needs evidence for faults before publication, during publication alias transition, and after publication but before successful return. -Current native evidence covers interrupted copy, Unix permission denial, Windows ACL denial, kernel-enforced write failure, actual macOS `ENOSPC`, and real-public-publisher process termination at three deterministic publication boundaries. None of these states may invent a durable project reference, delete buyer bytes on pathname authority, or misreport an interrupted publisher as a successful attachment. +Current native evidence covers interrupted copy, Unix permission denial, Windows ACL denial, kernel-enforced write failure, actual macOS `ENOSPC`, and real-public-publisher process termination at three deterministic publication boundaries. The desktop package also has an owner-only executable harness that invokes the same public publisher from a separate process so process termination can be exercised outside the core integration-test executable. None of these states may invent a durable project reference, delete buyer bytes on pathname authority, or misreport an interrupted publisher as a successful attachment. ## Repair lineage @@ -26,7 +26,13 @@ Windows ACL denial is covered separately by `score-attachment-windows-acl-recove Review of that repair identified a release-safety requirement: a deliberate indefinite wait must never become reachable in a shipped build merely because a feature is accidentally enabled. `61f94a4b076d83109703aedde51f73f1eb9e47e3` adds a compile-time fail-closed guard for `score-storage-fault-injection` whenever `debug_assertions` are absent. `03b6b3c14f65ba80dcfeb0a5180dcc8b4c9d8543` adds owner checks that the normal Tauri desktop dependency feature graph excludes the feature and that an explicit release-mode core build with the feature is rejected. Exact `0be5683c2a7ea17d89e0b5deeb88c5e0bea0134d` completed owner run `35534785802` successfully on macOS 15 job `106141747352` and Windows Server 2025 job `106141747629`, including both feature-exclusion evidence and the pre-metadata-barrier termination regression. -That still left two earlier production boundaries represented only by manually constructed filesystem fixtures. `8f3186938c7754a24cbfdd31fbdcfa81a1415eea` extends the real-public-publisher regression with required `after-stage-sync-before-link` and `after-link-before-stage-retirement` checkpoints. At that test-only head those checkpoints did not exist, so both new cases are deterministic behavioral source-level REDs. `1a381460d0ee9a219a1bbc871b9db3b078accb41` makes the existing owner-only checkpoint helper crate-visible without changing default-build behavior. `1f1377c219f885996dccdd81168ae23529fe4a55` places the two new checkpoints in the lower publication owner: one after the synchronized stage handle is closed and before `hard_link`, the other immediately after `hard_link` and before destination attestation/stage retirement. Default builds still execute a no-op helper; release builds still fail closed if the owner-test feature is enabled. +That still left two earlier production boundaries represented only by manually constructed filesystem fixtures. `8f3186938c7754a24cbfdd31fbdcfa81a1415eea` extends the real-public-publisher regression with required `after-stage-sync-before-link` and `after-link-before-stage-retirement` checkpoints. At that test-only head those checkpoints did not exist, so both new cases are deterministic behavioral source-level REDs. `1a381460d0ee9a219a1bbc871b9db3b078accb41` makes the existing owner-only checkpoint helper crate-visible without changing default-build behavior. `1f1377c219f885996dccdd81168ae23529fe4a55` places the two new checkpoints in the lower publication owner: one after the synchronized stage handle is closed and before `hard_link`, the other immediately after `hard_link` and before destination attestation/stage retirement. Default builds still execute a no-op helper; release builds still fail closed if the owner-test feature is enabled. Exact `ffb426fe1e7ce75f5eff7a8ae7953142dd1ff0fc` then completed owner run `35535178354` successfully on macOS 15 and Windows Server 2025 with all three real-public-publisher termination cases. + +The next slice moved process-lifecycle evidence one packaging layer outward without changing the shipped Tauri application. `81096ccb0cbe22c593db9d4cff2cd19877662453` introduced a desktop-package process-termination regression, `8170def44fea860c0ab0b82235f6e15c821671e5` propagated the owner-only fault feature through the desktop manifest, and `c63002d2f343407886425172f9c652564bf0edc2` plus `a832db1b3f5c1c5dfc4ff69fdfa20a0cffb59ada` added `score-storage-fault-harness`, a separate binary target that exists only when the owner-test feature is explicitly enabled. It calls the real public Score Storage publisher but does not expose Tauri commands or UI authority. + +Two hosted failures were valid CI/fixture findings rather than Score Storage behavioral failures. Exact `e66decc01676e1864beeb87762b713441d9bf917` reached `score-storage-native` run `35536126369`, where macOS tried to compile the ordinary Tauri main while preparing the new integration-test target and failed at `tauri::generate_context!()` because the owner lane intentionally has no `../dist` frontend bundle. `0fd88675ab1fa6b6a06902ec6b7b1db5d0024286` narrowed the command with `--bin score-storage-fault-harness`, but run `35536338595`, macOS job `106145940467`, still compiled the package integration-test graph and failed at the same missing-frontend boundary before any new termination assertion. These are hosted target-selection/build-fixture REDs; neither is evidence that publication or recovery behaved incorrectly. + +The causal repair keeps the product build contract intact rather than manufacturing a fake frontend or weakening `generate_context!()`. `8dbbbbc923767562a3413d675d69bc69e72f7bbe` moves the process driver to `apps/desktop/core/tests/score_pdf_desktop_package_termination_recovery.rs`; `949b62d024aa21b7c0340562d507ec5593481c3d` removes the src-tauri integration-test target that forced the ordinary Tauri main into the test graph. `c670904108f60dd7f1f2a0ca5747ecb38b09f8ff` builds only the owner-only desktop-package harness into an isolated `CARGO_TARGET_DIR`, passes that exact executable path to the core owner regression, and then terminates the external harness at the same three publication checkpoints. The normal desktop feature graph must still exclude the fault feature, and both core and desktop-package release-mode builds with that feature must continue to fail closed. ## Fault semantics @@ -38,7 +44,7 @@ The kernel write-failure regression creates a 128 KiB PDF-shaped source, then st The capacity-exhaustion regression is deliberately different from `RLIMIT_FSIZE`. The selected 1 MiB source lives outside the isolated image. The test fills and synchronizes the fixed-size image until real `ENOSPC`, removes only a 512 KiB reserve, and invokes the public publisher. Publication must fail before destination truth is created; the owned partial stage must be retired, the source must remain unchanged and restart inventory must be empty. -The real-publisher termination suite now owns three checkpoints under the same public API and workspace lease: +Both process-termination suites exercise three checkpoints under the same public API and workspace lease. The core suite runs the real public publisher from an integration-test child. The desktop-package suite separately builds and launches the owner-only `score-storage-fault-harness` binary from the desktop package, then drives recovery from the core owner test: - `after-stage-sync-before-link`: synchronized stage exists, destination does not. After process termination, fresh recovery must retire the abandoned stage and return no published receipt. - `after-link-before-stage-retirement`: stage and destination both exist with the same buyer bytes. After termination, fresh recovery must retain the destination, retire only the equal-content temporary alias and return exactly one path-free receipt. @@ -50,12 +56,12 @@ At every checkpoint the parent first requires live inventory to fail because the **Untrusted state.** Every directory entry and every stage byte stream is untrusted at restart. -**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content and native deletion authority. Process-termination checkpoints are owner-test instrumentation behind an explicit Cargo feature and exact environment selector; they add no runtime command, network surface or product mutation authority. Release builds fail to compile if the feature is enabled, and the desktop dependency feature graph is checked separately so the shipped manifest cannot silently opt into it. +**Trust boundary.** Exact reserved-name parsing identifies candidate temporary state; cleanup still passes through the Score Storage lease, no-follow/reparse checks, bounded admission content and native deletion authority. Process-termination checkpoints are owner-test instrumentation behind an explicit Cargo feature and exact environment selector; they add no runtime command, network surface or product mutation authority. The desktop-package harness is a separate required-feature binary, not a Tauri command. Release builds fail to compile if the feature is enabled, and the normal desktop dependency feature graph is checked separately so the shipped manifest cannot silently opt into it. **Safe failure.** Incomplete or stage-only state never becomes destination truth. Equal stage+destination state retires only the temporary alias after validated content equality. A process killed after stage retirement but before the successful-return barrier leaves published object truth for restart inventory, not a fabricated Project Persistence reference or guessed delete. -**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, Unix kernel-enforced file-size write failure, macOS fixed-filesystem `ENOSPC`, and real-public-publisher process termination at the three publication checkpoints above. Exact `0be5683c...` is historical hosted GREEN for the pre-metadata-barrier case and feature exclusion; the newer three-boundary source requires fresh exact-head owner settlement before it is called hosted GREEN. None of this proves packaged desktop-app cancellation, sudden power loss, Windows capacity exhaustion or Windows directory-entry successful-return durability. +**Claim boundary.** Current source evidence covers interrupted partial copy, Unix permission denial, Windows-native ACL denial, Unix kernel-enforced file-size write failure, macOS fixed-filesystem `ENOSPC`, real-public-publisher process termination at all three publication checkpoints, and an owner-only desktop-package executable that invokes the same public publisher at those checkpoints. The desktop-package harness is not the ordinary `bandscope-desktop` Tauri main, is not bundled/notarized/signed product output, does not exercise Tauri command/UI cancellation, and does not prove installer or OS shutdown semantics. None of this proves sudden power loss, Windows capacity exhaustion or Windows directory-entry successful-return durability. ## Next buyer gap -After the current three-boundary exact head settles, remaining process-lifecycle evidence is the packaged desktop executable rather than the owner-native integration-test child. Sudden-power-loss/directory-entry durability follows after that. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race and coexistence with an older unleased BandScope build remain explicit storage-authority gaps. +After the desktop-package harness exact head settles, the remaining process-lifecycle gap is the actual shipped/bundled desktop application: terminate the real product process through a supported packaged test route without weakening the frontend/build contract, and verify the same restart semantics. Sudden-power-loss/directory-entry durability follows after that. Windows directory-entry durability remains unclaimed until a supported native primitive and recovery experiment justify it. The separate Unix final basename identity-check-to-`unlinkat` micro-race and coexistence with an older unleased BandScope build remain explicit storage-authority gaps. From d8f38b86da9db7f96a41135cdab4e3912bb02b8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:04:56 +0900 Subject: [PATCH 149/166] test(score): require receipt-bound detach deletion --- .../core/tests/score_pdf_attachment_wiring.rs | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs index 662742d42..9ac38694f 100644 --- a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs +++ b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs @@ -1,4 +1,6 @@ const TAURI_MAIN: &str = include_str!("../../src-tauri/src/main.rs"); +const SCORE_STORAGE_BRIDGE: &str = include_str!("../../src/features/score/scoreStorage.ts"); +const SCORE_VIEW: &str = include_str!("../../src/features/score/ScoreView.tsx"); #[test] fn tauri_attachment_command_uses_score_storage_publication_boundary() { @@ -16,16 +18,52 @@ fn tauri_attachment_command_uses_score_storage_publication_boundary() { } #[test] -fn tauri_remove_command_uses_score_storage_deletion_boundary() { - let remove_start = TAURI_MAIN - .find("fn remove_score_pdf(") - .expect("remove_score_pdf command should exist"); +fn tauri_remove_command_requires_a_fresh_content_receipt() { + let receipt_start = TAURI_MAIN + .find("fn get_score_pdf_receipt(") + .expect("get_score_pdf_receipt command should expose path-free object freshness"); + let remove_start = TAURI_MAIN[receipt_start..] + .find("fn remove_score_pdf_if_receipt_matches(") + .map(|offset| receipt_start + offset) + .expect("receipt-bound remove command should follow receipt lookup"); let main_start = TAURI_MAIN[remove_start..] .find("fn main()") .map(|offset| remove_start + offset) - .expect("main should follow remove_score_pdf command"); + .expect("main should follow receipt-bound remove command"); + let receipt_source = &TAURI_MAIN[receipt_start..remove_start]; let remove_source = &TAURI_MAIN[remove_start..main_start]; - assert!(remove_source.contains("remove_score_pdf_attachment(")); - assert!(!remove_source.contains("std::fs::remove_file(")); + assert!(receipt_source.contains("published_score_pdf_receipt(")); + assert!(remove_source.contains("published_score_pdf_receipt(")); + assert!(remove_source.contains("remove_score_pdf_attachment_if_receipt_matches(")); + assert!(!remove_source.contains("remove_score_pdf_attachment(")); + assert!(!TAURI_MAIN.contains("fn remove_score_pdf(")); +} + +#[test] +fn score_view_detach_reads_receipt_before_metadata_and_deletes_only_by_receipt() { + let remove_start = SCORE_VIEW + .find("const handleRemove = async") + .expect("ScoreView should define the detach interaction"); + let render_start = SCORE_VIEW[remove_start..] + .find("\n return (") + .map(|offset| remove_start + offset) + .expect("ScoreView render should follow detach interaction"); + let remove_source = &SCORE_VIEW[remove_start..render_start]; + + let receipt = remove_source + .find("getScorePdfReceipt(") + .expect("detach should capture current storage identity before metadata mutation"); + let metadata = remove_source + .find("onSongUpdate(") + .expect("detach should persist metadata removal"); + let deletion = remove_source + .find("removeScorePdfIfReceiptMatches(") + .expect("detach should delete only through receipt-bound storage authority"); + + assert!(receipt < metadata, "storage identity must be captured before durable metadata detachment"); + assert!(metadata < deletion, "buyer metadata must be detached before destructive byte deletion"); + assert!(SCORE_STORAGE_BRIDGE.contains("get_score_pdf_receipt")); + assert!(SCORE_STORAGE_BRIDGE.contains("remove_score_pdf_if_receipt_matches")); + assert!(!SCORE_STORAGE_BRIDGE.contains("\"remove_score_pdf\"")); } From c8dbe1c00495624e9edf0a6b1a90aa37aab31a21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:12:55 +0900 Subject: [PATCH 150/166] fix(score): bind detach deletion to content receipt --- .github/workflows/score-storage-native.yml | 30 +++++++ CHANGELOG.md | 3 +- apps/desktop/src-tauri/src/main.rs | 79 ++++++++++++++++--- .../ScoreView.persistenceOutcome.test.tsx | 53 ++++++++++--- apps/desktop/src/features/score/ScoreView.tsx | 22 ++++-- .../src/features/score/scoreStorage.test.ts | 70 ++++++++++++++-- .../src/features/score/scoreStorage.ts | 52 +++++++++++- ...core-attachment-recovery-object-receipt.md | 15 +++- 8 files changed, 281 insertions(+), 43 deletions(-) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 9ebdd8cff..1cd6d80a8 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -20,6 +20,10 @@ on: - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" + - "apps/desktop/src/features/score/ScoreView.tsx" + - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" + - "apps/desktop/src/features/score/scoreStorage.ts" + - "apps/desktop/src/features/score/scoreStorage.test.ts" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -48,6 +52,10 @@ on: - "apps/desktop/src-tauri/Cargo.toml" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" + - "apps/desktop/src/features/score/ScoreView.tsx" + - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" + - "apps/desktop/src/features/score/scoreStorage.ts" + - "apps/desktop/src/features/score/scoreStorage.test.ts" - "docs/traceability/score-attachment-publication.md" - "docs/traceability/score-attachment-recovery-inventory.md" - "docs/traceability/score-attachment-recovery-object-receipt.md" @@ -163,3 +171,25 @@ jobs: --manifest-path apps/desktop/core/Cargo.toml \ --features score-storage-fault-injection \ --test score_pdf_desktop_package_termination_recovery + + score-storage-ui: + name: test / score-storage / ui + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22.22.3 + package-manager-cache: false + - name: Install locked JavaScript dependencies + run: npm ci + - name: Run receipt-bound score detach regressions + run: >- + npm exec --workspace=@bandscope/desktop -- vitest run + src/features/score/scoreStorage.test.ts + src/features/score/ScoreView.persistenceOutcome.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 147655925..b29b3a660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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. - 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. - Recover process-abandoned score-PDF staging before the next attachment publication under an OS-released cross-process workspace lease; ambiguous stage-plus-destination state is preserved and fails closed instead of being deleted by a blind sweep. +- Bind buyer-facing score detachment to the path-free SHA-256 receipt captured before project metadata changes, and revalidate that receipt through Score Storage before destructive deletion so same-id replacement bytes are preserved instead of being recaptured by stale intent. ## [0.1.3] - 2026-04-29 @@ -77,4 +78,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index d04003f2f..eee18fa49 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -783,6 +783,33 @@ fn scores_root_for_project( Ok(root) } +fn published_score_pdf_receipt( + scores_root: &Path, + score_id: &str, +) -> Result, String> { + if !is_valid_score_id(score_id) { + return Err("Invalid score id.".to_string()); + } + let receipts = inventory_published_score_pdf_receipts(scores_root)?; + Ok(receipts + .into_iter() + .find(|receipt| receipt.score_id() == score_id)) +} + +fn is_valid_score_content_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ScorePdfReceiptPayload { + score_id: String, + content_sha256: String, +} + /// Security Notes: the selected path comes only from the OS file dialog and is /// admitted as a bounded, non-symlink PDF before publication. Score Storage /// reopens that source once, copies only the descriptor-length snapshot into a @@ -840,18 +867,16 @@ fn read_score_pdf( read_validated_score_pdf(&path) } -/// Security Notes: same id validation and traversal guard as `read_score_pdf`. -/// Score Storage distinguishes a genuinely absent directory entry from unsafe -/// or indeterminate resolution before invoking object-bound deletion. Only the -/// observed-absent case returns `false`; symlink, non-regular, containment, and -/// I/O failures remain errors so the UI cannot silently discard attachment -/// metadata while storage is still present or unverified. +/// Return a path-free content receipt for the currently published score object. +/// A valid but absent score returns `None`; suspicious workspace state remains an +/// error. The receipt is freshness evidence only and does not authorize a +/// lifecycle decision by itself. #[tauri::command] -fn remove_score_pdf( +fn get_score_pdf_receipt( project_id: String, score_id: String, app: tauri::AppHandle, -) -> Result { +) -> Result, String> { if !is_valid_project_id(&project_id) { return Err("Invalid project id.".to_string()); } @@ -859,11 +884,40 @@ fn remove_score_pdf( return Err("Invalid score id.".to_string()); } let scores_root = scores_root_for_project(&app, &project_id)?; - let Some(path) = resolve_score_pdf_for_removal(&scores_root, &score_id)? else { + Ok(published_score_pdf_receipt(&scores_root, &score_id)?.map(|receipt| { + ScorePdfReceiptPayload { + score_id: receipt.score_id().to_string(), + content_sha256: receipt.content_sha256().to_string(), + } + })) +} + +/// Delete score bytes only when the buyer-facing detach flow presents the same +/// content identity it captured before durable project metadata was changed. +/// The current object is re-read before mutation and the core owner revalidates +/// the receipt again while holding the Score Storage lease. Missing or changed +/// bytes are safe non-removals; no id-only deletion fallback exists. +#[tauri::command] +fn remove_score_pdf_if_receipt_matches( + project_id: String, + score_id: String, + content_sha256: String, + app: tauri::AppHandle, +) -> Result { + if !is_valid_project_id(&project_id) { + return Err("Invalid project id.".to_string()); + } + if !is_valid_score_id(&score_id) || !is_valid_score_content_sha256(&content_sha256) { + return Err("Invalid score receipt.".to_string()); + } + let scores_root = scores_root_for_project(&app, &project_id)?; + let Some(receipt) = published_score_pdf_receipt(&scores_root, &score_id)? else { return Ok(false); }; - remove_score_pdf_attachment(&path)?; - Ok(true) + if receipt.content_sha256() != content_sha256 { + return Ok(false); + } + remove_score_pdf_attachment_if_receipt_matches(&scores_root, &receipt) } fn main() { @@ -878,7 +932,8 @@ fn main() { load_project, attach_score_pdf, read_score_pdf, - remove_score_pdf + get_score_pdf_receipt, + remove_score_pdf_if_receipt_matches ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx b/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx index 67a8788e4..9d5048fa2 100644 --- a/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx @@ -2,12 +2,18 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { ScoreView } from "./ScoreView"; -import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; +import { + attachScorePdf, + getScorePdfReceipt, + readScorePdf, + removeScorePdfIfReceiptMatches +} from "./scoreStorage"; vi.mock("./scoreStorage", () => ({ attachScorePdf: vi.fn(), + getScorePdfReceipt: vi.fn(), readScorePdf: vi.fn(), - removeScorePdf: vi.fn() + removeScorePdfIfReceiptMatches: vi.fn() })); vi.mock("./ScoreViewer", () => ({ @@ -41,9 +47,11 @@ vi.mock("../../i18n", () => ({ })); const mockAttachScorePdf = vi.mocked(attachScorePdf); +const mockGetScorePdfReceipt = vi.mocked(getScorePdfReceipt); const mockReadScorePdf = vi.mocked(readScorePdf); -const mockRemoveScorePdf = vi.mocked(removeScorePdf); +const mockRemoveScorePdfIfReceiptMatches = vi.mocked(removeScorePdfIfReceiptMatches); const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const RECEIPT = { scoreId: SCORE_ID, contentSha256: "ab".repeat(32) }; function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { return { @@ -58,8 +66,9 @@ function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { describe("ScoreView persistence outcome ordering", () => { beforeEach(() => { mockAttachScorePdf.mockReset(); + mockGetScorePdfReceipt.mockReset(); mockReadScorePdf.mockReset(); - mockRemoveScorePdf.mockReset(); + mockRemoveScorePdfIfReceiptMatches.mockReset(); }); afterEach(() => { @@ -82,12 +91,13 @@ describe("ScoreView persistence outcome ordering", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Add score" })).toBeEnabled()); expect(mockAttachScorePdf).toHaveBeenCalledTimes(1); expect(mockReadScorePdf).not.toHaveBeenCalled(); - expect(mockRemoveScorePdf).not.toHaveBeenCalled(); + expect(mockRemoveScorePdfIfReceiptMatches).not.toHaveBeenCalled(); expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); }); - it("does not delete score bytes when durable metadata removal is rejected", async () => { + it("captures storage identity but does not delete bytes when durable metadata removal is rejected", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); + mockGetScorePdfReceipt.mockResolvedValue(RECEIPT); const onSongUpdate = vi.fn().mockResolvedValue(false); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); @@ -96,22 +106,43 @@ describe("ScoreView persistence outcome ordering", () => { fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); - expect(mockRemoveScorePdf).not.toHaveBeenCalled(); + expect(mockGetScorePdfReceipt).toHaveBeenCalledWith("project-1-2", SCORE_ID); + expect(mockRemoveScorePdfIfReceiptMatches).not.toHaveBeenCalled(); }); - it("commits metadata removal before deleting the stored score object", async () => { + it("captures a receipt before metadata removal and deletes only by that receipt afterward", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); + mockGetScorePdfReceipt.mockResolvedValue(RECEIPT); const onSongUpdate = vi.fn().mockResolvedValue(true); - mockRemoveScorePdf.mockResolvedValue(true); + mockRemoveScorePdfIfReceiptMatches.mockResolvedValue(true); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); render(); fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); - await waitFor(() => expect(mockRemoveScorePdf).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockRemoveScorePdfIfReceiptMatches).toHaveBeenCalledTimes(1)); + expect(mockRemoveScorePdfIfReceiptMatches).toHaveBeenCalledWith("project-1-2", RECEIPT); + expect(mockGetScorePdfReceipt.mock.invocationCallOrder[0]).toBeLessThan( + onSongUpdate.mock.invocationCallOrder[0] + ); expect(onSongUpdate.mock.invocationCallOrder[0]).toBeLessThan( - mockRemoveScorePdf.mock.invocationCallOrder[0] + mockRemoveScorePdfIfReceiptMatches.mock.invocationCallOrder[0] ); }); + + it("detaches broken metadata without inventing delete authority when storage is already absent", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + mockGetScorePdfReceipt.mockResolvedValue(null); + const onSongUpdate = vi.fn().mockResolvedValue(true); + const song = makeSong([{ id: SCORE_ID, fileName: "missing.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: missing.pdf" })); + + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + expect(mockGetScorePdfReceipt).toHaveBeenCalledWith("project-1-2", SCORE_ID); + expect(mockRemoveScorePdfIfReceiptMatches).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index d3b610ff9..b16c33155 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -5,7 +5,12 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { ScoreViewer } from "./ScoreViewer"; -import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; +import { + attachScorePdf, + getScorePdfReceipt, + readScorePdf, + removeScorePdfIfReceiptMatches +} from "./scoreStorage"; /** Props accepted by the per-song score attachments view. */ export interface ScoreViewProps { @@ -108,11 +113,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { }; /** - * Detach metadata before destructive byte deletion. If the project owner - * rejects the metadata commit, the stored PDF remains intact and referenced. - * Once metadata is accepted, a later storage-delete failure can leave an - * unreferenced recovery/cleanup candidate but cannot create a durable project - * reference to bytes that this interaction already deleted. + * Capture the exact Score Storage object identity before changing durable + * project metadata, then delete bytes only if that same receipt is still + * current after metadata detachment. A missing object needs no deletion; a + * changed object is preserved as a recovery candidate rather than recaptured + * by id-only authority. */ const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { const confirmed = window.confirm( @@ -123,6 +128,7 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { } setError(null); try { + const receipt = await getScorePdfReceipt(activeProjectId, attachment.id); const accepted = await onSongUpdate({ ...song, scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) @@ -136,7 +142,9 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { setPdfBytes(null); setIsOpening(false); } - await removeScorePdf(activeProjectId, attachment.id); + if (receipt) { + await removeScorePdfIfReceiptMatches(activeProjectId, receipt); + } } catch (removeError) { setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); } diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199e..3ee7f69fa 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; +import { + attachScorePdf, + getScorePdfReceipt, + readScorePdf, + removeScorePdfIfReceiptMatches +} from "./scoreStorage"; type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown; @@ -7,6 +12,8 @@ type TauriWindow = Window & { }; const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const DIGEST = "ab".repeat(32); describe("scoreStorage bridge resolution", () => { afterEach(() => { @@ -17,19 +24,70 @@ describe("scoreStorage bridge resolution", () => { }); it("fails closed on every command when there is no window (non-browser runtime)", async () => { - // Simulate a runtime without a DOM window (e.g. SSR / bundler prerender): - // getInvoke() must take the `typeof window === "undefined"` branch and - // return null so callers fail closed instead of dereferencing `window`. vi.stubGlobal("window", undefined); await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); - await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + await expect(readScorePdf("project-1", SCORE_ID)).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); - await expect(removeScorePdf("project-1", "score-1")).rejects.toThrow( + await expect(getScorePdfReceipt("project-1", SCORE_ID)).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); + await expect( + removeScorePdfIfReceiptMatches("project-1", { scoreId: SCORE_ID, contentSha256: DIGEST }) + ).rejects.toThrow(BRIDGE_UNAVAILABLE_MESSAGE); + }); + + it("passes only path-free receipt identity through the detach bridge", async () => { + const invoke = vi.fn(async (command: string) => { + if (command === "get_score_pdf_receipt") { + return { scoreId: SCORE_ID, contentSha256: DIGEST }; + } + if (command === "remove_score_pdf_if_receipt_matches") { + return true; + } + throw new Error(`unexpected command ${command}`); + }); + (window as TauriWindow).__TAURI_INVOKE__ = invoke; + + const receipt = await getScorePdfReceipt("project-1", SCORE_ID); + expect(receipt).toEqual({ scoreId: SCORE_ID, contentSha256: DIGEST }); + await expect(removeScorePdfIfReceiptMatches("project-1", receipt!)).resolves.toBe(true); + + expect(invoke).toHaveBeenNthCalledWith(1, "get_score_pdf_receipt", { + projectId: "project-1", + scoreId: SCORE_ID + }); + expect(invoke).toHaveBeenNthCalledWith(2, "remove_score_pdf_if_receipt_matches", { + projectId: "project-1", + scoreId: SCORE_ID, + contentSha256: DIGEST + }); + }); + + it("rejects a receipt that does not bind the requested score id", async () => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + scoreId: "different-score-id", + contentSha256: DIGEST + }); + + await expect(getScorePdfReceipt("project-1", SCORE_ID)).rejects.toThrow( + "Invalid score bridge response" + ); + }); + + it("rejects malformed digest input before destructive bridge invocation", async () => { + const invoke = vi.fn(); + (window as TauriWindow).__TAURI_INVOKE__ = invoke; + + await expect( + removeScorePdfIfReceiptMatches("project-1", { + scoreId: SCORE_ID, + contentSha256: "not-a-sha256" + }) + ).rejects.toThrow("Invalid score bridge response"); + expect(invoke).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f12591..09e905b7b 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -14,8 +14,12 @@ type TauriBridgeWindow = Window & { */ export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; +/** Path-free content identity for one currently published score object. */ +export type ScorePdfReceipt = { scoreId: string; contentSha256: string }; + const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const SHA256_HEX = /^[0-9a-f]{64}$/; /** * Resolve the desktop invoke bridge following the same detection rules as @@ -99,11 +103,51 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< } /** - * Delete the stored score PDF copy. Resolves to false when the file was - * already gone so callers can treat removal as idempotent. + * Snapshot path-free object identity before a buyer-facing detach changes + * durable project metadata. A missing object is represented as `null`; malformed + * bridge payloads fail closed instead of becoming deletion authority. + */ +export async function getScorePdfReceipt( + projectId: string, + scoreId: string +): Promise { + const response = await invokeScoreCommand("get_score_pdf_receipt", { projectId, scoreId }); + if (response === null) { + return null; + } + if ( + typeof response !== "object" || + typeof (response as Record).scoreId !== "string" || + typeof (response as Record).contentSha256 !== "string" + ) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + + const payload = response as ScorePdfReceipt; + if (payload.scoreId !== scoreId || !SHA256_HEX.test(payload.contentSha256)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + return payload; +} + +/** + * Delete a stored score only if the current object still matches the receipt + * captured before durable metadata detachment. A false result is a safe + * non-removal for an absent or changed object; callers must not fall back to + * id-only deletion. */ -export async function removeScorePdf(projectId: string, scoreId: string): Promise { - const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId }); +export async function removeScorePdfIfReceiptMatches( + projectId: string, + receipt: ScorePdfReceipt +): Promise { + if (!receipt.scoreId || !SHA256_HEX.test(receipt.contentSha256)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + const response = await invokeScoreCommand("remove_score_pdf_if_receipt_matches", { + projectId, + scoreId: receipt.scoreId, + contentSha256: receipt.contentSha256 + }); if (typeof response !== "boolean") { throw new Error(INVALID_RESPONSE_MESSAGE); } diff --git a/docs/traceability/score-attachment-recovery-object-receipt.md b/docs/traceability/score-attachment-recovery-object-receipt.md index dce127a68..b94d3dc4f 100644 --- a/docs/traceability/score-attachment-recovery-object-receipt.md +++ b/docs/traceability/score-attachment-recovery-object-receipt.md @@ -4,6 +4,8 @@ The restart inventory introduced for #1239 deliberately returned validated `score_id` values only. That was sufficient for discovery but not for mutation authority. A score can be removed and another PDF can later be published under the same valid id. A recovery decision made for the first object would then have the same logical id as the replacement object even though the buyer bytes changed. Treating the id as durable object identity creates a same-id ABA window for recovery `Discard` and any later mutation that relies on stale inventory state. +A later buyer-path review found the same authority defect in the ordinary Score detach interaction. The core receipt-bound remover existed, but `ScoreView` detached project metadata and then invoked an id-only Tauri deletion command. That command reconstructed the current path from `project_id + score_id` and called the lower native remover directly. If the original object was replaced under the same score id while the async metadata update was in flight, the stale detach intent could recapture the replacement object as new deletion authority. + ## Constraint and owner boundary Score Storage owns published PDF byte/object truth, its cross-process workspace lease, bounded PDF validation and native identity-safe deletion. Project Persistence owns durable project references and recovery intent. This change does not let Project Persistence scan the score directory, infer lifecycle state, or delete bytes directly. @@ -18,6 +20,10 @@ The receipt is therefore narrow and path-free: validated `score_id` plus lowerca The existing id-only inventory remains available for discovery compatibility. Recovery mutation must use the receipt-bearing contract when object freshness matters. +The buyer-facing detach flow now uses the same object-freshness contract. The desktop bridge captures a path-free receipt before calling the Project Persistence callback that removes attachment metadata. Only after that callback reports acceptance does the UI submit the captured `{score_id, content_sha256}` back to Score Storage. The Tauri command re-reads current Score Storage receipt state and then delegates to `remove_score_pdf_attachment_if_receipt_matches`, which performs its own lease-scoped revalidation again immediately before deletion. A score already absent at receipt capture needs no deletion. A score replaced after receipt capture is preserved as an unreferenced recovery candidate; there is no id-only fallback. + +This ordering is intentionally narrower than full restart recovery orchestration. It does not decide Recover / Preserve / Discard, does not solve project-switch invalidation while an async project commit is in flight, and does not convert a safe non-removal into automatic cleanup authority. Those remain Project Persistence/application integration work. + ## Shared kernel adoption The SHA-256 implementation is the same generic `content_sha256` shared-kernel implementation already present on Project Persistence #970. This lane adopts that existing implementation rather than adding a second algorithm or dependency. When the owner stacks are reconciled, the identical shared-kernel delta must be consolidated rather than retained as parallel source copies. @@ -32,12 +38,15 @@ Causal repair is the receipt-bearing Score Storage contract: path-free receipt i Exact `b252cac4abf91d2722a2f9e87e8cbf949dd4d41d` produced terminal macOS and Windows owner failures before the intended ABA assertion. Both platforms passed Score Storage unit tests and the shared SHA-256 known-answer test, then the new integration fixture failed its first production publication with `Could not recover the score workspace.`. RCA showed the test created only the fixture parent while production admission correctly requires the app-owned `scores` workspace to pre-exist before acquiring its lease. `ee7995e222756aa04f6f1cd83ed2dd5e2d928afe` fixes only the fixture by creating `scores_root`; production admission was not weakened and the failed run is not evidence against the receipt contract. +Source RED `d8f38b86da9db7f96a41135cdab4e3912bb02b8f` moves the same freshness requirement into the actual buyer detach wiring. It rejects a Tauri command that can delete by id/path authority alone and requires receipt capture before metadata mutation followed by receipt-bound deletion after accepted metadata persistence. Because the repair follows before any terminal hosted run is used as behavioral evidence, this commit is source-level RED unless a hosted run independently reaches and fails the intended wiring assertions. + ## Security notes - Untrusted state includes score workspace directory entries, PDF bytes and stale recovery decisions. - The Score Storage workspace lease serializes current-contract writers from recovery through comparison and deletion. - Exact score-name validation, existing containment checks, bounded PDF reads and existing native deletion identity checks remain in force. - A digest mismatch never falls back to id-only deletion. +- Buyer detach captures receipt identity before project metadata mutation and revalidates it again through the owner boundary after metadata acceptance. - Errors do not disclose buyer filesystem paths or PDF bytes. - The receipt does not authorize a lifecycle decision by itself. Project Persistence/application orchestration must still prove that the candidate remains eligible and that the buyer chose the requested action. @@ -45,8 +54,10 @@ Exact `b252cac4abf91d2722a2f9e87e8cbf949dd4d41d` produced terminal macOS and Win Lifetime non-reuse tombstones were rejected for this slice because they would add a new durable lifecycle sidecar and compatibility/migration semantics solely to compensate for logical-id reuse. Filesystem inode/file-id alone was rejected because object identifiers can be reused and are platform-specific. Revalidating a receipt and then releasing the lease before deletion was rejected because it recreates a TOCTOU window. Hashing a path without the existing bounded/contained read boundary was rejected because it would create a second filesystem authority. Weakening production admission so tests may publish into a missing workspace was also rejected; app-owned workspace creation belongs to its existing orchestration boundary. +Keeping the existing buyer-facing id-only Tauri delete command was rejected because native identity-safe deletion protects against pathname substitution during one call but cannot distinguish a stale buyer intent for object A from later replacement object B under the same logical id. Deleting first and then persisting project metadata was rejected because a failed project commit can leave a durable reference to bytes that the interaction already destroyed. + ## Remaining work -The application recovery flow must consume fresh Score Storage receipts and active Project Persistence project identity immediately before Recover/Preserve/Discard execution. Recover still requires durable Project Persistence CAS acceptance before presentation as an accepted attachment. Discard must call the receipt-bound Score Storage mutation rather than id-only deletion. `missing_referenced_score_ids` remains a broken-attachment state, not cleanup authority. +Full recovery orchestration must consume fresh Score Storage receipts and active Project Persistence project identity immediately before Recover / Preserve / Discard execution. Recover still requires durable Project Persistence CAS acceptance before presentation as an accepted attachment. `missing_referenced_score_ids` remains a broken-attachment state, not cleanup authority. Project-switch invalidation during async mutation, project deletion/recovery rollback and UI recovery choices remain separate buyer-visible work. -Protected integration, independent review, packaged process-kill/cancellation/disk-full/permission/power-loss evidence, Windows ACL revalidation, signing/notarization, provenance/reproducibility and immutable release/update rollback remain separate gates. +Protected integration, independent review, actual shipped/bundled application cancellation, sudden-power-loss evidence, verified Windows directory-entry durability, old-unleased-build coexistence, signing/notarization, provenance/reproducibility and immutable release/update rollback remain separate gates. From a24637faff184b91abaa2ecc11a9e0cb042d9a70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:16:48 +0900 Subject: [PATCH 151/166] test(score): currentize retention contract for receipt delete --- .../core/tests/score_pdf_retention_resolution.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/desktop/core/tests/score_pdf_retention_resolution.rs b/apps/desktop/core/tests/score_pdf_retention_resolution.rs index 57c410f4b..c15e2ec96 100644 --- a/apps/desktop/core/tests/score_pdf_retention_resolution.rs +++ b/apps/desktop/core/tests/score_pdf_retention_resolution.rs @@ -79,16 +79,18 @@ fn removal_resolution_rejects_a_symlink_instead_of_reporting_absent() { const TAURI_MAIN: &str = include_str!("../../src-tauri/src/main.rs"); #[test] -fn tauri_removal_preserves_unsafe_resolution_errors() { +fn tauri_receipt_bound_removal_preserves_unsafe_workspace_errors() { let remove_start = TAURI_MAIN - .find("fn remove_score_pdf(") - .expect("remove_score_pdf command should exist"); + .find("fn remove_score_pdf_if_receipt_matches(") + .expect("receipt-bound remove command should exist"); let main_start = TAURI_MAIN[remove_start..] .find("fn main()") .map(|offset| remove_start + offset) - .expect("main should follow remove_score_pdf command"); + .expect("main should follow receipt-bound remove command"); let remove_source = &TAURI_MAIN[remove_start..main_start]; - assert!(remove_source.contains("resolve_score_pdf_for_removal(")); + assert!(remove_source.contains("published_score_pdf_receipt(&scores_root, &score_id)?")); + assert!(remove_source.contains("remove_score_pdf_attachment_if_receipt_matches(")); assert!(!remove_source.contains("Err(_) => return Ok(false)")); + assert!(!remove_source.contains("remove_score_pdf_attachment(&")); } From dc19a7e826b813f6380f76fe97317d5ecf689f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:21:16 +0900 Subject: [PATCH 152/166] test(score): require receipt commands in Tauri manifest --- .../core/tests/score_pdf_attachment_wiring.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs index 9ac38694f..54d520210 100644 --- a/apps/desktop/core/tests/score_pdf_attachment_wiring.rs +++ b/apps/desktop/core/tests/score_pdf_attachment_wiring.rs @@ -1,4 +1,6 @@ const TAURI_MAIN: &str = include_str!("../../src-tauri/src/main.rs"); +const TAURI_BUILD: &str = include_str!("../../src-tauri/build.rs"); +const TAURI_CAPABILITY: &str = include_str!("../../src-tauri/capabilities/main.json"); const SCORE_STORAGE_BRIDGE: &str = include_str!("../../src/features/score/scoreStorage.ts"); const SCORE_VIEW: &str = include_str!("../../src/features/score/ScoreView.tsx"); @@ -40,6 +42,17 @@ fn tauri_remove_command_requires_a_fresh_content_receipt() { assert!(!TAURI_MAIN.contains("fn remove_score_pdf(")); } +#[test] +fn tauri_manifest_and_capability_expose_only_receipt_bound_detach_commands() { + assert!(TAURI_BUILD.contains("\"get_score_pdf_receipt\"")); + assert!(TAURI_BUILD.contains("\"remove_score_pdf_if_receipt_matches\"")); + assert!(!TAURI_BUILD.contains("\"remove_score_pdf\"")); + + assert!(TAURI_CAPABILITY.contains("\"allow-get-score-pdf-receipt\"")); + assert!(TAURI_CAPABILITY.contains("\"allow-remove-score-pdf-if-receipt-matches\"")); + assert!(!TAURI_CAPABILITY.contains("\"allow-remove-score-pdf\"")); +} + #[test] fn score_view_detach_reads_receipt_before_metadata_and_deletes_only_by_receipt() { let remove_start = SCORE_VIEW From e34270807e548d10be980359d1770dcc6dbb6260 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 06:24:49 +0900 Subject: [PATCH 153/166] fix(score): register receipt-bound Tauri detach --- .github/workflows/score-storage-native.yml | 11 ++++++ apps/desktop/src-tauri/build.rs | 5 +-- apps/desktop/src-tauri/capabilities/main.json | 5 +-- .../autogenerated/get_score_pdf_receipt.toml | 11 ++++++ .../autogenerated/remove_score_pdf.toml | 11 ------ .../remove_score_pdf_if_receipt_matches.toml | 11 ++++++ .../src/features/score/ScoreView.test.tsx | 34 ++++++++++++++----- 7 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/get_score_pdf_receipt.toml delete mode 100644 apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf_if_receipt_matches.toml diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index 1cd6d80a8..c6a0b88bd 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -18,9 +18,14 @@ on: - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/build.rs" + - "apps/desktop/src-tauri/capabilities/main.json" + - "apps/desktop/src-tauri/permissions/autogenerated/*.toml" + - "apps/desktop/src-tauri/gen/schemas/*.json" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src/features/score/ScoreView.tsx" + - "apps/desktop/src/features/score/ScoreView.test.tsx" - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" - "apps/desktop/src/features/score/scoreStorage.ts" - "apps/desktop/src/features/score/scoreStorage.test.ts" @@ -50,9 +55,14 @@ on: - "apps/desktop/core/tests/content_sha256_shared_kernel.rs" - "apps/desktop/core/tests/score_pdf_*.rs" - "apps/desktop/src-tauri/Cargo.toml" + - "apps/desktop/src-tauri/build.rs" + - "apps/desktop/src-tauri/capabilities/main.json" + - "apps/desktop/src-tauri/permissions/autogenerated/*.toml" + - "apps/desktop/src-tauri/gen/schemas/*.json" - "apps/desktop/src-tauri/src/main.rs" - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src/features/score/ScoreView.tsx" + - "apps/desktop/src/features/score/ScoreView.test.tsx" - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" - "apps/desktop/src/features/score/scoreStorage.ts" - "apps/desktop/src/features/score/scoreStorage.test.ts" @@ -191,5 +201,6 @@ jobs: - name: Run receipt-bound score detach regressions run: >- npm exec --workspace=@bandscope/desktop -- vitest run + src/features/score/ScoreView.test.tsx src/features/score/scoreStorage.test.ts src/features/score/ScoreView.persistenceOutcome.test.tsx diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0caf74c64..df4066f3e 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -9,8 +9,9 @@ fn main() { "load_project", "attach_score_pdf", "read_score_pdf", - "remove_score_pdf", + "get_score_pdf_receipt", + "remove_score_pdf_if_receipt_matches", ]), )) .expect("failed to build tauri application manifest"); -} +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 8103420f4..b29fb4649 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -14,6 +14,7 @@ "allow-load-project", "allow-attach-score-pdf", "allow-read-score-pdf", - "allow-remove-score-pdf" + "allow-get-score-pdf-receipt", + "allow-remove-score-pdf-if-receipt-matches" ] -} +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/permissions/autogenerated/get_score_pdf_receipt.toml b/apps/desktop/src-tauri/permissions/autogenerated/get_score_pdf_receipt.toml new file mode 100644 index 000000000..c92b444e3 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/get_score_pdf_receipt.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-score-pdf-receipt" +description = "Enables the get_score_pdf_receipt command without any pre-configured scope." +commands.allow = ["get_score_pdf_receipt"] + +[[permission]] +identifier = "deny-get-score-pdf-receipt" +description = "Denies the get_score_pdf_receipt command without any pre-configured scope." +commands.deny = ["get_score_pdf_receipt"] \ No newline at end of file diff --git a/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml b/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml deleted file mode 100644 index 8133491d8..000000000 --- a/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -[[permission]] -identifier = "allow-remove-score-pdf" -description = "Enables the remove_score_pdf command without any pre-configured scope." -commands.allow = ["remove_score_pdf"] - -[[permission]] -identifier = "deny-remove-score-pdf" -description = "Denies the remove_score_pdf command without any pre-configured scope." -commands.deny = ["remove_score_pdf"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf_if_receipt_matches.toml b/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf_if_receipt_matches.toml new file mode 100644 index 000000000..ef6df2dec --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf_if_receipt_matches.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-remove-score-pdf-if-receipt-matches" +description = "Enables the remove_score_pdf_if_receipt_matches command without any pre-configured scope." +commands.allow = ["remove_score_pdf_if_receipt_matches"] + +[[permission]] +identifier = "deny-remove-score-pdf-if-receipt-matches" +description = "Denies the remove_score_pdf_if_receipt_matches command without any pre-configured scope." +commands.deny = ["remove_score_pdf_if_receipt_matches"] \ No newline at end of file diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..cf820d69c 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -47,6 +47,7 @@ const tauriWindow = window as TauriWindow; const mockInvoke = vi.mocked(invoke); const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const SCORE_DIGEST = "ab".repeat(32); function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { return { @@ -223,6 +224,7 @@ describe("ScoreView", () => { it("removes an attachment after confirmation and resets the open viewer", async () => { mockInvoke .mockResolvedValueOnce([1, 2]) + .mockResolvedValueOnce({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }) .mockResolvedValueOnce(true); vi.spyOn(window, "confirm").mockReturnValue(true); const onSongUpdate = vi.fn(); @@ -241,10 +243,15 @@ describe("ScoreView", () => { expect(onSongUpdate).toHaveBeenCalledWith({ ...song, scoreAttachments: [] }); }); expect(window.confirm).toHaveBeenCalledWith("Remove opener.pdf from this song?"); - expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf", { + expect(mockInvoke).toHaveBeenCalledWith("get_score_pdf_receipt", { projectId: "project-1-2", scoreId: SCORE_ID }); + expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf_if_receipt_matches", { + projectId: "project-1-2", + scoreId: SCORE_ID, + contentSha256: SCORE_DIGEST + }); expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); }); @@ -261,8 +268,8 @@ describe("ScoreView", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); - it("reports removal failures without dropping the metadata", async () => { - mockInvoke.mockRejectedValueOnce(new Error("Could not remove the score PDF.")); + it("reports receipt lookup failures without dropping the metadata", async () => { + mockInvoke.mockRejectedValueOnce(new Error("Could not inspect the score PDF.")); vi.spyOn(window, "confirm").mockReturnValue(true); const onSongUpdate = vi.fn(); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); @@ -271,25 +278,27 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Could not remove the score PDF."); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not inspect the score PDF."); expect(onSongUpdate).not.toHaveBeenCalled(); }); - it("rejects malformed removal responses", async () => { + it("rejects malformed receipt responses before metadata removal", async () => { mockInvoke.mockResolvedValueOnce("done"); vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + expect(onSongUpdate).not.toHaveBeenCalled(); }); it("fails closed when no desktop bridge is available", async () => { @@ -397,9 +406,11 @@ describe("ScoreView", () => { }); it("removes a score that is not currently open without resetting the viewer", async () => { - // With nothing open, removal updates metadata but must leave the (empty) - // viewer state untouched. - mockInvoke.mockResolvedValueOnce(true); + // With nothing open, receipt-bound removal updates metadata but must leave + // the (empty) viewer state untouched. + mockInvoke + .mockResolvedValueOnce({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }) + .mockResolvedValueOnce(true); vi.spyOn(window, "confirm").mockReturnValue(true); const onSongUpdate = vi.fn(); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); @@ -411,6 +422,11 @@ describe("ScoreView", () => { await waitFor(() => { expect(onSongUpdate).toHaveBeenCalledWith({ ...song, scoreAttachments: [] }); }); + expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf_if_receipt_matches", { + projectId: "project-1-2", + scoreId: SCORE_ID, + contentSha256: SCORE_DIGEST + }); expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); }); }); From 470aceb8c0e2d76f935cc0ed326204007b0c0674 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:03:38 +0900 Subject: [PATCH 154/166] test(score): expose stale project-context score mutations --- .github/workflows/score-storage-native.yml | 7 +- .../score/ScoreView.projectContext.test.tsx | 153 ++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/features/score/ScoreView.projectContext.test.tsx diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index c6a0b88bd..b2c8d16c4 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -26,6 +26,7 @@ on: - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src/features/score/ScoreView.tsx" - "apps/desktop/src/features/score/ScoreView.test.tsx" + - "apps/desktop/src/features/score/ScoreView.projectContext.test.tsx" - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" - "apps/desktop/src/features/score/scoreStorage.ts" - "apps/desktop/src/features/score/scoreStorage.test.ts" @@ -63,6 +64,7 @@ on: - "apps/desktop/src-tauri/src/bin/score-storage-fault-harness.rs" - "apps/desktop/src/features/score/ScoreView.tsx" - "apps/desktop/src/features/score/ScoreView.test.tsx" + - "apps/desktop/src/features/score/ScoreView.projectContext.test.tsx" - "apps/desktop/src/features/score/ScoreView.persistenceOutcome.test.tsx" - "apps/desktop/src/features/score/scoreStorage.ts" - "apps/desktop/src/features/score/scoreStorage.test.ts" @@ -198,9 +200,10 @@ jobs: package-manager-cache: false - name: Install locked JavaScript dependencies run: npm ci - - name: Run receipt-bound score detach regressions + - name: Run receipt-bound score detach and project-context regressions run: >- npm exec --workspace=@bandscope/desktop -- vitest run src/features/score/ScoreView.test.tsx + src/features/score/ScoreView.projectContext.test.tsx src/features/score/scoreStorage.test.ts - src/features/score/ScoreView.persistenceOutcome.test.tsx + src/features/score/ScoreView.persistenceOutcome.test.tsx \ No newline at end of file diff --git a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx new file mode 100644 index 000000000..e879c91fe --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx @@ -0,0 +1,153 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; +import { invoke } from "@tauri-apps/api/core"; +import { ScoreView } from "./ScoreView"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => ( +
+ {data ? `bytes:${data.length}` : "no-data"} + {fileName ? `:${fileName}` : ""} +
+ ) +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreListEmpty: "No scores attached to this song yet.", + scoreAttach: "Add score", + scoreAttaching: "Attaching...", + scoreRemove: "Remove", + scoreRemoveConfirm: "Remove {fileName} from this song?", + scoreOpen: "Open score", + scoreOpening: "Opening score PDF...", + scoreAttachFailed: "Could not attach the score PDF.", + scoreReadFailed: "Could not open the score PDF.", + scoreRemoveFailed: "Could not remove the score PDF.", + scoreRequiresProject: "Scores attach to the active analysis project." + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown }; +const tauriWindow = window as TauriWindow; +const mockInvoke = vi.mocked(invoke); +const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const SCORE_DIGEST = "ab".repeat(32); + +function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { + return { + id: "song-1", + title: "Late Night Set", + sections: [], + exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }, + ...(scoreAttachments ? { scoreAttachments } : {}) + } as RehearsalSong; +} + +function attachResponse() { + return { scoreId: SCORE_ID, fileName: "opener.pdf", fileSizeBytes: 2048 }; +} + +describe("ScoreView project-context invalidation", () => { + beforeEach(() => { + mockInvoke.mockReset(); + tauriWindow.__TAURI_INTERNALS__ = { invoke: () => Promise.resolve(null) }; + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("does not render a read that resolves after the active project changes", async () => { + let resolveRead!: (value: unknown) => void; + mockInvoke.mockImplementationOnce(() => new Promise((resolve) => { resolveRead = resolve; })); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + const onSongUpdate = vi.fn(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + expect(await screen.findByText("Opening score PDF...")).toBeInTheDocument(); + + rerender(); + await act(async () => { resolveRead([1, 2, 3]); }); + + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("leaves a completed old-project attachment for recovery instead of mutating the new project", async () => { + let resolveAttach!: (value: unknown) => void; + mockInvoke.mockImplementationOnce(() => new Promise((resolve) => { resolveAttach = resolve; })); + const song = makeSong(); + const onSongUpdate = vi.fn(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + rerender(); + await act(async () => { resolveAttach(attachResponse()); }); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockInvoke).toHaveBeenCalledWith("attach_score_pdf", { + projectId: "project-a", + songId: "song-1" + }); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); + + it("does not detach metadata when an old-project receipt resolves after a project switch", async () => { + let resolveReceipt!: (value: unknown) => void; + mockInvoke.mockImplementationOnce(() => new Promise((resolve) => { resolveReceipt = resolve; })); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + const onSongUpdate = vi.fn(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + rerender(); + await act(async () => { + resolveReceipt({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }); + }); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockInvoke).toHaveBeenCalledWith("get_score_pdf_receipt", { + projectId: "project-a", + scoreId: SCORE_ID + }); + }); + + it("does not delete old-project bytes when project context changes during metadata persistence", async () => { + mockInvoke.mockResolvedValueOnce({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }); + let resolvePersistence!: (value: void | boolean) => void; + const onSongUpdate = vi.fn( + () => new Promise((resolve) => { resolvePersistence = resolve; }) + ); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + + rerender(); + await act(async () => { resolvePersistence(true); }); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockInvoke).not.toHaveBeenCalledWith( + "remove_score_pdf_if_receipt_matches", + expect.anything() + ); + }); +}); From 7dfa1c6c7c8100619dd3eafcd9f01b153fd3cb9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:04:28 +0900 Subject: [PATCH 155/166] fix(score): invalidate stale project-context score operations --- apps/desktop/src/features/score/ScoreView.tsx | 79 ++++++++++++++----- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index b16c33155..8569067e9 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { createTranslator, detectPreferredLocale } from "../../i18n"; @@ -55,13 +55,38 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const [isOpening, setIsOpening] = useState(false); const [error, setError] = useState(null); const readRequestRef = useRef(0); + const contextKey = `${projectId ?? ""}\u0000${song.id}`; + const contextKeyRef = useRef(contextKey); + const previousContextKeyRef = useRef(contextKey); + contextKeyRef.current = contextKey; + + /** Return whether an async operation still belongs to the rendered project/song context. */ + const isCurrentContext = (expectedContextKey: string) => + contextKeyRef.current === expectedContextKey; + + useEffect(() => { + if (previousContextKeyRef.current === contextKey) { + return; + } + previousContextKeyRef.current = contextKey; + readRequestRef.current += 1; + setSelected(null); + setPdfBytes(null); + setIsOpening(false); + setIsAttaching(false); + setError(null); + }, [contextKey]); /** - * Load the stored PDF bytes for an attachment into the viewer. Callers pass - * the active project id explicitly; the storage controls are only wired up - * (and enabled) when a workspace is present, so this never runs without one. + * Load the stored PDF bytes for an attachment into the viewer. The caller's + * project/song context is captured with the request so a late result cannot + * repaint a different project after navigation. */ - const openAttachment = async (activeProjectId: string, attachment: ScoreAttachment) => { + const openAttachment = async ( + activeProjectId: string, + attachment: ScoreAttachment, + expectedContextKey = contextKeyRef.current + ) => { const requestId = readRequestRef.current + 1; readRequestRef.current = requestId; setSelected(attachment); @@ -70,16 +95,16 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { setIsOpening(true); try { const bytes = await readScorePdf(activeProjectId, attachment.id); - if (readRequestRef.current === requestId) { + if (readRequestRef.current === requestId && isCurrentContext(expectedContextKey)) { setPdfBytes(bytes); } } catch (readError) { - if (readRequestRef.current === requestId) { + if (readRequestRef.current === requestId && isCurrentContext(expectedContextKey)) { setSelected(null); setError(`${t("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim()); } } finally { - if (readRequestRef.current === requestId) { + if (readRequestRef.current === requestId && isCurrentContext(expectedContextKey)) { setIsOpening(false); } } @@ -87,39 +112,48 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { /** * Attach a new score PDF via the native picker and open it only after the - * owning project metadata accepts the attachment. A rejected async metadata - * commit deliberately leaves the already-published PDF as a recovery - * candidate rather than deleting buyer bytes without lifecycle authority. + * owning project metadata accepts the attachment. A completed publication + * whose project context has since changed is left as a recovery candidate; + * stale UI intent never mutates the newly active project. */ const handleAttach = async (activeProjectId: string) => { + const expectedContextKey = contextKeyRef.current; setError(null); setIsAttaching(true); try { const result = await attachScorePdf(activeProjectId, song.id); + if (!isCurrentContext(expectedContextKey)) { + return; + } const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName }; const accepted = await onSongUpdate({ ...song, scoreAttachments: [...attachments, attachment] }); - if (accepted === false) { + if (!isCurrentContext(expectedContextKey) || accepted === false) { return; } - await openAttachment(activeProjectId, attachment); + await openAttachment(activeProjectId, attachment, expectedContextKey); } catch (attachError) { - setError(bridgeErrorDetail(attachError, t("scoreAttachFailed"))); + if (isCurrentContext(expectedContextKey)) { + setError(bridgeErrorDetail(attachError, t("scoreAttachFailed"))); + } } finally { - setIsAttaching(false); + if (isCurrentContext(expectedContextKey)) { + setIsAttaching(false); + } } }; /** * Capture the exact Score Storage object identity before changing durable * project metadata, then delete bytes only if that same receipt is still - * current after metadata detachment. A missing object needs no deletion; a - * changed object is preserved as a recovery candidate rather than recaptured - * by id-only authority. + * current after metadata detachment. The project/song context is also + * revalidated before metadata mutation and again before storage deletion, so + * an async detach cannot cross a project switch. */ const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { + const expectedContextKey = contextKeyRef.current; const confirmed = window.confirm( t("scoreRemoveConfirm").replace("{fileName}", attachment.fileName) ); @@ -129,11 +163,14 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { setError(null); try { const receipt = await getScorePdfReceipt(activeProjectId, attachment.id); + if (!isCurrentContext(expectedContextKey)) { + return; + } const accepted = await onSongUpdate({ ...song, scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) }); - if (accepted === false) { + if (!isCurrentContext(expectedContextKey) || accepted === false) { return; } if (selected?.id === attachment.id) { @@ -146,7 +183,9 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { await removeScorePdfIfReceiptMatches(activeProjectId, receipt); } } catch (removeError) { - setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); + if (isCurrentContext(expectedContextKey)) { + setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); + } } }; From bc74d9bb90c8ebf18039c56557171f064514fdb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:05:52 +0900 Subject: [PATCH 156/166] docs(score): trace project-context freshness boundary --- .../score-attachment-project-context.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/traceability/score-attachment-project-context.md diff --git a/docs/traceability/score-attachment-project-context.md b/docs/traceability/score-attachment-project-context.md new file mode 100644 index 000000000..1c53ae129 --- /dev/null +++ b/docs/traceability/score-attachment-project-context.md @@ -0,0 +1,41 @@ +# Score attachment project-context freshness + +Issue: #1239 +Canonical owner: Score Storage / Score Attachment UI boundary + +## Problem + +ScoreView can remain mounted while the active project changes. Before this repair, asynchronous score work captured an old `projectId` but had no project/song freshness guard. A late read could repaint the new project, a completed old-project attach could call `onSongUpdate` against the newly active UI state, and an old detach could continue from receipt lookup or metadata persistence into storage deletion after navigation. + +Receipt-bound storage identity prevents same-id object ABA, but it does not by itself prove that the UI intent still belongs to the active project. Project-context freshness is therefore a separate application invariant. + +## Decision + +ScoreView derives a context key from `(projectId, song.id)` and keeps the current key in a render-time ref. Every async read, attach and detach captures the key at operation start and rechecks it before any later UI mutation or lifecycle step. + +A project/song change invalidates the visible score selection, PDF bytes, read request generation, opening/attaching state and stale error state. The render-time ref changes before effects run, so an old promise resolving between the new render and the cleanup effect still fails the freshness check. + +Attach rechecks context after native publication and again after project metadata acceptance. If native publication completed for the old project after navigation, ScoreView does not attach that result to the new project and does not delete the bytes; the object remains a recovery candidate for higher-level reconciliation. + +Detach rechecks context after receipt acquisition and again after project metadata acceptance. It therefore never invokes receipt-bound Score Storage deletion from an intent that crossed a project switch. Project Persistence remains owner of durable active-project identity and CAS semantics; this UI guard does not replace #970. + +## RED and repair + +`470aceb8c0e2d76f935cc0ed326204007b0c0674` adds focused UI regressions for four stale-context cases: late read, late attach, receipt lookup resolving after a project switch, and project switching while metadata persistence is pending. The previous ScoreView implementation would violate those expectations. + +`7dfa1c6c7c8100619dd3eafcd9f01b153fd3cb9b` adds the minimal context-key invalidation and revalidation logic in ScoreView. The tests are owned by `score-storage-native` through the focused Ubuntu UI job. + +No hosted GREEN is claimed until an exact-current-head owner run reaches terminal success. + +## Invariants and safe failure + +- Old-project reads never update selection, PDF bytes, opening state or errors in the new project. +- Old-project attach completion never calls project metadata mutation for the new context. +- Old-project detach receipt completion never starts metadata mutation for the new context. +- If navigation occurs while detach metadata persistence is pending, Score Storage deletion is suppressed after the persistence result returns. +- A stale operation does not manufacture compensating deletion authority. Published bytes remain recoverable rather than being guessed away. +- Same-project metadata rerenders do not invalidate work because the key is based on project id and song id, not object identity. + +## Claim boundary + +This closes the in-component async project-switch race for ScoreView. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. From a7634e48a5c6aeb76d8738f0c0519b198d9b2bae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:06:20 +0900 Subject: [PATCH 157/166] test(score): isolate project-context regression state --- .../src/features/score/ScoreView.projectContext.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx index e879c91fe..3816ffec5 100644 --- a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { invoke } from "@tauri-apps/api/core"; import { ScoreView } from "./ScoreView"; @@ -63,6 +63,11 @@ describe("ScoreView project-context invalidation", () => { vi.spyOn(window, "confirm").mockReturnValue(true); }); + afterEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + vi.restoreAllMocks(); + }); + it("does not render a read that resolves after the active project changes", async () => { let resolveRead!: (value: unknown) => void; mockInvoke.mockImplementationOnce(() => new Promise((resolve) => { resolveRead = resolve; })); From 70c4cebf49be49855a236a125d7079bd14627dc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:14:14 +0900 Subject: [PATCH 158/166] ci(score): own project-context traceability --- .github/workflows/score-storage-native.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/score-storage-native.yml b/.github/workflows/score-storage-native.yml index b2c8d16c4..0211de925 100644 --- a/.github/workflows/score-storage-native.yml +++ b/.github/workflows/score-storage-native.yml @@ -38,6 +38,7 @@ on: - "docs/traceability/score-attachment-unix-stage-cleanup.md" - "docs/traceability/score-attachment-fault-recovery.md" - "docs/traceability/score-attachment-windows-acl-recovery.md" + - "docs/traceability/score-attachment-project-context.md" - ".github/workflows/score-storage-native.yml" push: branches: @@ -76,6 +77,7 @@ on: - "docs/traceability/score-attachment-unix-stage-cleanup.md" - "docs/traceability/score-attachment-fault-recovery.md" - "docs/traceability/score-attachment-windows-acl-recovery.md" + - "docs/traceability/score-attachment-project-context.md" - ".github/workflows/score-storage-native.yml" permissions: From e95de494fd3747a09fd23780188a474d51dd26ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:22:18 +0900 Subject: [PATCH 159/166] fix(score): clear stale project visuals before paint --- apps/desktop/src/features/score/ScoreView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 8569067e9..db9ec09db 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { createTranslator, detectPreferredLocale } from "../../i18n"; @@ -64,7 +64,7 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const isCurrentContext = (expectedContextKey: string) => contextKeyRef.current === expectedContextKey; - useEffect(() => { + useLayoutEffect(() => { if (previousContextKeyRef.current === contextKey) { return; } From d2c58b67229188af8998a55d50ca9f763c19fa8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:22:33 +0900 Subject: [PATCH 160/166] docs(score): record pre-paint project-context boundary --- docs/traceability/score-attachment-project-context.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/traceability/score-attachment-project-context.md b/docs/traceability/score-attachment-project-context.md index 1c53ae129..6de89f7e8 100644 --- a/docs/traceability/score-attachment-project-context.md +++ b/docs/traceability/score-attachment-project-context.md @@ -9,11 +9,13 @@ ScoreView can remain mounted while the active project changes. Before this repai Receipt-bound storage identity prevents same-id object ABA, but it does not by itself prove that the UI intent still belongs to the active project. Project-context freshness is therefore a separate application invariant. +The first context-key repair used a passive React effect to clear the previously selected score after a project/song change. That protected later asynchronous continuation, but React explicitly permits passive effects to run after the browser paints. For buyer-visible score content, a single stale Project A paint while Project B is already active is not an acceptable confidentiality/UI boundary. + ## Decision ScoreView derives a context key from `(projectId, song.id)` and keeps the current key in a render-time ref. Every async read, attach and detach captures the key at operation start and rechecks it before any later UI mutation or lifecycle step. -A project/song change invalidates the visible score selection, PDF bytes, read request generation, opening/attaching state and stale error state. The render-time ref changes before effects run, so an old promise resolving between the new render and the cleanup effect still fails the freshness check. +A project/song change invalidates the visible score selection, PDF bytes, read request generation, opening/attaching state and stale error state in `useLayoutEffect`, so the state reset and resulting rerender complete before the browser repaint. The render-time ref changes even earlier, during render, so an old promise resolving between the new render and the layout effect already fails the freshness check. Attach rechecks context after native publication and again after project metadata acceptance. If native publication completed for the old project after navigation, ScoreView does not attach that result to the new project and does not delete the bytes; the object remains a recovery candidate for higher-level reconciliation. @@ -23,13 +25,14 @@ Detach rechecks context after receipt acquisition and again after project metada `470aceb8c0e2d76f935cc0ed326204007b0c0674` adds focused UI regressions for four stale-context cases: late read, late attach, receipt lookup resolving after a project switch, and project switching while metadata persistence is pending. The previous ScoreView implementation would violate those expectations. -`7dfa1c6c7c8100619dd3eafcd9f01b153fd3cb9b` adds the minimal context-key invalidation and revalidation logic in ScoreView. The tests are owned by `score-storage-native` through the focused Ubuntu UI job. +`7dfa1c6c7c8100619dd3eafcd9f01b153fd3cb9b` adds context-key invalidation and async revalidation. Owner self-review then compared the visible-state reset against React's documented effect timing: passive `useEffect` may allow a paint before its state reset, while `useLayoutEffect` processes its state updates before repaint. `e95de494fd3747a09fd23780188a474d51dd26ef` therefore changes only that visual invalidation boundary from passive to layout effect; async authority and storage semantics are unchanged. -No hosted GREEN is claimed until an exact-current-head owner run reaches terminal success. +The focused regressions are owned by `score-storage-native` through the Ubuntu UI job. Hosted GREEN belongs only to an unchanged exact current head; predecessor native/UI verdicts do not transfer across this source change. ## Invariants and safe failure - Old-project reads never update selection, PDF bytes, opening state or errors in the new project. +- Already-rendered old-project score content is cleared before the browser can paint the newly active project/song context. - Old-project attach completion never calls project metadata mutation for the new context. - Old-project detach receipt completion never starts metadata mutation for the new context. - If navigation occurs while detach metadata persistence is pending, Score Storage deletion is suppressed after the persistence result returns. @@ -38,4 +41,4 @@ No hosted GREEN is claimed until an exact-current-head owner run reaches termina ## Claim boundary -This closes the in-component async project-switch race for ScoreView. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. +This closes the in-component asynchronous project-switch continuation and pre-paint stale-score reset boundary for ScoreView. The focused jsdom regression proves the state/lifecycle authority cases; the pre-paint timing claim follows the React client rendering contract and the use of `useLayoutEffect`, not a synthetic browser-paint timer. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. From 721bddc70081329c860c267cb3b83ce73531a3c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:30:02 +0900 Subject: [PATCH 161/166] test(score): cover detach selection race --- .../score/ScoreView.projectContext.test.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx index 3816ffec5..8be49928b 100644 --- a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx @@ -155,4 +155,33 @@ describe("ScoreView project-context invalidation", () => { expect.anything() ); }); -}); + + it("clears a score opened while detach waits for its receipt once metadata detachment is accepted", async () => { + let resolveReceipt!: (value: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((resolve) => { resolveReceipt = resolve; })) + .mockResolvedValueOnce([1, 2, 3]) + .mockResolvedValueOnce(true); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + const onSongUpdate = vi.fn(() => true); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + await waitFor(() => expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf")); + + await act(async () => { + resolveReceipt({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }); + }); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf_if_receipt_matches", { + projectId: "project-a", + scoreId: SCORE_ID, + contentSha256: SCORE_DIGEST + }); + }); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); +}); \ No newline at end of file From 9f63bd53db01949670f35a7ed8cdf7cee46bc10e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:30:52 +0900 Subject: [PATCH 162/166] fix(score): clear current selection after accepted detach --- apps/desktop/src/features/score/ScoreView.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index db9ec09db..7d11283df 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -55,9 +55,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const [isOpening, setIsOpening] = useState(false); const [error, setError] = useState(null); const readRequestRef = useRef(0); + const selectedRef = useRef(selected); const contextKey = `${projectId ?? ""}\u0000${song.id}`; const contextKeyRef = useRef(contextKey); const previousContextKeyRef = useRef(contextKey); + selectedRef.current = selected; contextKeyRef.current = contextKey; /** Return whether an async operation still belongs to the rendered project/song context. */ @@ -70,6 +72,7 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { } previousContextKeyRef.current = contextKey; readRequestRef.current += 1; + selectedRef.current = null; setSelected(null); setPdfBytes(null); setIsOpening(false); @@ -89,6 +92,7 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { ) => { const requestId = readRequestRef.current + 1; readRequestRef.current = requestId; + selectedRef.current = attachment; setSelected(attachment); setPdfBytes(null); setError(null); @@ -100,6 +104,7 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { } } catch (readError) { if (readRequestRef.current === requestId && isCurrentContext(expectedContextKey)) { + selectedRef.current = null; setSelected(null); setError(`${t("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim()); } @@ -150,7 +155,9 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { * project metadata, then delete bytes only if that same receipt is still * current after metadata detachment. The project/song context is also * revalidated before metadata mutation and again before storage deletion, so - * an async detach cannot cross a project switch. + * an async detach cannot cross a project switch. Selection freshness is read + * at acceptance time so a score opened while receipt/persistence work is in + * flight cannot remain visible after its metadata is detached. */ const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { const expectedContextKey = contextKeyRef.current; @@ -173,8 +180,9 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { if (!isCurrentContext(expectedContextKey) || accepted === false) { return; } - if (selected?.id === attachment.id) { + if (selectedRef.current?.id === attachment.id) { readRequestRef.current += 1; + selectedRef.current = null; setSelected(null); setPdfBytes(null); setIsOpening(false); @@ -294,4 +302,4 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { )} ); -} +} \ No newline at end of file From 0062f1ffd9ef46a6f06a8d13d03a4e228347ff4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 07:31:15 +0900 Subject: [PATCH 163/166] docs(score): trace detach selection freshness --- docs/traceability/score-attachment-project-context.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/traceability/score-attachment-project-context.md b/docs/traceability/score-attachment-project-context.md index 6de89f7e8..42871945a 100644 --- a/docs/traceability/score-attachment-project-context.md +++ b/docs/traceability/score-attachment-project-context.md @@ -11,12 +11,16 @@ Receipt-bound storage identity prevents same-id object ABA, but it does not by i The first context-key repair used a passive React effect to clear the previously selected score after a project/song change. That protected later asynchronous continuation, but React explicitly permits passive effects to run after the browser paints. For buyer-visible score content, a single stale Project A paint while Project B is already active is not an acceptable confidentiality/UI boundary. +A second same-context race remained after that repair. Detach captured the render-time `selected` value before awaiting the content receipt and project metadata persistence. A buyer could open the same score while detach was waiting; after metadata detachment was accepted, the stale closure still saw the earlier selection and could leave the now-detached PDF visible in the viewer even while Score Storage deletion proceeded. + ## Decision ScoreView derives a context key from `(projectId, song.id)` and keeps the current key in a render-time ref. Every async read, attach and detach captures the key at operation start and rechecks it before any later UI mutation or lifecycle step. A project/song change invalidates the visible score selection, PDF bytes, read request generation, opening/attaching state and stale error state in `useLayoutEffect`, so the state reset and resulting rerender complete before the browser repaint. The render-time ref changes even earlier, during render, so an old promise resolving between the new render and the layout effect already fails the freshness check. +Score selection also has a live ref synchronized with each selection transition. Detach consults that ref only after durable metadata acceptance. If the attachment being detached became selected while receipt lookup or metadata persistence was in flight, detach invalidates the read generation and clears selection, bytes and opening state before storage deletion. The decision is therefore based on the current same-context viewer state, not the render that initiated detach. + Attach rechecks context after native publication and again after project metadata acceptance. If native publication completed for the old project after navigation, ScoreView does not attach that result to the new project and does not delete the bytes; the object remains a recovery candidate for higher-level reconciliation. Detach rechecks context after receipt acquisition and again after project metadata acceptance. It therefore never invokes receipt-bound Score Storage deletion from an intent that crossed a project switch. Project Persistence remains owner of durable active-project identity and CAS semantics; this UI guard does not replace #970. @@ -27,6 +31,8 @@ Detach rechecks context after receipt acquisition and again after project metada `7dfa1c6c7c8100619dd3eafcd9f01b153fd3cb9b` adds context-key invalidation and async revalidation. Owner self-review then compared the visible-state reset against React's documented effect timing: passive `useEffect` may allow a paint before its state reset, while `useLayoutEffect` processes its state updates before repaint. `e95de494fd3747a09fd23780188a474d51dd26ef` therefore changes only that visual invalidation boundary from passive to layout effect; async authority and storage semantics are unchanged. +`721bddc70081329c860c267cb3b83ce73531a3c8` adds the same-context selection RED: remove begins while receipt lookup is pending, the buyer opens that score, metadata detachment is then accepted, and the viewer must no longer display the detached score. The previous closure-based `selected` check retained the bytes. `9f63bd53db01949670f35a7ed8cdf7cee46bc10e` repairs the cause by tracking live selection identity and clearing the current detached selection after metadata acceptance. The RED head did not receive terminal hosted evidence before the repair descendant, so no hosted RED is claimed. + The focused regressions are owned by `score-storage-native` through the Ubuntu UI job. Hosted GREEN belongs only to an unchanged exact current head; predecessor native/UI verdicts do not transfer across this source change. ## Invariants and safe failure @@ -36,9 +42,10 @@ The focused regressions are owned by `score-storage-native` through the Ubuntu U - Old-project attach completion never calls project metadata mutation for the new context. - Old-project detach receipt completion never starts metadata mutation for the new context. - If navigation occurs while detach metadata persistence is pending, Score Storage deletion is suppressed after the persistence result returns. +- If the score being detached becomes selected while same-context detach is in flight, accepted metadata detachment invalidates that current selection and its bytes before storage deletion. - A stale operation does not manufacture compensating deletion authority. Published bytes remain recoverable rather than being guessed away. - Same-project metadata rerenders do not invalidate work because the key is based on project id and song id, not object identity. ## Claim boundary -This closes the in-component asynchronous project-switch continuation and pre-paint stale-score reset boundary for ScoreView. The focused jsdom regression proves the state/lifecycle authority cases; the pre-paint timing claim follows the React client rendering contract and the use of `useLayoutEffect`, not a synthetic browser-paint timer. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. +This closes the in-component asynchronous project-switch continuation, pre-paint stale-score reset, and same-context detach-selection freshness boundaries for ScoreView. The focused jsdom regressions prove the state/lifecycle authority cases; the pre-paint timing claim follows the React client rendering contract and the use of `useLayoutEffect`, not a synthetic browser-paint timer. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. \ No newline at end of file From 8dd038be0f115377cda7071e2d49a909effb3013 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:02:15 +0900 Subject: [PATCH 164/166] test(score): preserve same-song updates across async attachment work --- .../score/ScoreView.projectContext.test.tsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx index 8be49928b..da5e8adf2 100644 --- a/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.projectContext.test.tsx @@ -40,6 +40,7 @@ type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown }; const tauriWindow = window as TauriWindow; const mockInvoke = vi.mocked(invoke); const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const OTHER_SCORE_ID = "4f2c8f0e-1a2b-4c3d-8e9f-001122334455"; const SCORE_DIGEST = "ab".repeat(32); function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { @@ -184,4 +185,71 @@ describe("ScoreView project-context invalidation", () => { expect(onSongUpdate).toHaveBeenCalledTimes(1); expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); }); + + it("attaches onto the latest same-song snapshot after native publication finishes", async () => { + let resolveAttach!: (value: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((resolve) => { resolveAttach = resolve; })) + .mockResolvedValueOnce([1, 2, 3]); + const initialSong = makeSong(); + const concurrentAttachment = { id: OTHER_SCORE_ID, fileName: "band-notes.pdf" }; + const updatedSong = { + ...makeSong([concurrentAttachment]), + title: "Late Night Set — revised" + }; + const onSongUpdate = vi.fn(() => true); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + rerender(); + await act(async () => { resolveAttach(attachResponse()); }); + + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + expect(onSongUpdate).toHaveBeenCalledWith({ + ...updatedSong, + scoreAttachments: [ + concurrentAttachment, + { id: SCORE_ID, fileName: "opener.pdf" } + ] + }); + }); + + it("detaches from the latest same-song snapshot after receipt lookup finishes", async () => { + let resolveReceipt!: (value: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((resolve) => { resolveReceipt = resolve; })) + .mockResolvedValueOnce(true); + const removableAttachment = { id: SCORE_ID, fileName: "opener.pdf" }; + const concurrentAttachment = { id: OTHER_SCORE_ID, fileName: "band-notes.pdf" }; + const initialSong = makeSong([removableAttachment]); + const updatedSong = { + ...makeSong([removableAttachment, concurrentAttachment]), + title: "Late Night Set — revised" + }; + const onSongUpdate = vi.fn(() => true); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + rerender(); + await act(async () => { + resolveReceipt({ scoreId: SCORE_ID, contentSha256: SCORE_DIGEST }); + }); + + await waitFor(() => expect(onSongUpdate).toHaveBeenCalledTimes(1)); + expect(onSongUpdate).toHaveBeenCalledWith({ + ...updatedSong, + scoreAttachments: [concurrentAttachment] + }); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf_if_receipt_matches", { + projectId: "project-a", + scoreId: SCORE_ID, + contentSha256: SCORE_DIGEST + }); + }); + }); }); \ No newline at end of file From 8529ec09254ae5c6c27d92454a1ae8a02629c27b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:02:46 +0900 Subject: [PATCH 165/166] fix(score): persist from latest same-song snapshot --- apps/desktop/src/features/score/ScoreView.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 7d11283df..f5cdc00a2 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -56,10 +56,12 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const [error, setError] = useState(null); const readRequestRef = useRef(0); const selectedRef = useRef(selected); + const songRef = useRef(song); const contextKey = `${projectId ?? ""}\u0000${song.id}`; const contextKeyRef = useRef(contextKey); const previousContextKeyRef = useRef(contextKey); selectedRef.current = selected; + songRef.current = song; contextKeyRef.current = contextKey; /** Return whether an async operation still belongs to the rendered project/song context. */ @@ -119,7 +121,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { * Attach a new score PDF via the native picker and open it only after the * owning project metadata accepts the attachment. A completed publication * whose project context has since changed is left as a recovery candidate; - * stale UI intent never mutates the newly active project. + * stale UI intent never mutates the newly active project. Within the same + * project/song, metadata is based on the latest rendered song snapshot before + * persistence; durable concurrent-writer arbitration remains Project + * Persistence/CAS authority. */ const handleAttach = async (activeProjectId: string) => { const expectedContextKey = contextKeyRef.current; @@ -131,9 +136,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { return; } const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName }; + const currentSong = songRef.current; + const currentAttachments = currentSong.scoreAttachments ?? []; const accepted = await onSongUpdate({ - ...song, - scoreAttachments: [...attachments, attachment] + ...currentSong, + scoreAttachments: [...currentAttachments, attachment] }); if (!isCurrentContext(expectedContextKey) || accepted === false) { return; @@ -155,9 +162,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { * project metadata, then delete bytes only if that same receipt is still * current after metadata detachment. The project/song context is also * revalidated before metadata mutation and again before storage deletion, so - * an async detach cannot cross a project switch. Selection freshness is read - * at acceptance time so a score opened while receipt/persistence work is in - * flight cannot remain visible after its metadata is detached. + * an async detach cannot cross a project switch. Selection freshness and the + * latest rendered same-song snapshot are read at acceptance time so receipt + * latency cannot leave a detached score visible or overwrite newer visible + * metadata. Durable concurrent-writer arbitration remains Project + * Persistence/CAS authority. */ const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { const expectedContextKey = contextKeyRef.current; @@ -173,9 +182,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { if (!isCurrentContext(expectedContextKey)) { return; } + const currentSong = songRef.current; + const currentAttachments = currentSong.scoreAttachments ?? []; const accepted = await onSongUpdate({ - ...song, - scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) + ...currentSong, + scoreAttachments: currentAttachments.filter((entry) => entry.id !== attachment.id) }); if (!isCurrentContext(expectedContextKey) || accepted === false) { return; @@ -302,4 +313,4 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { )} ); -} \ No newline at end of file +} From b29b7b522478780db44db1c754ab7f660ed2b17b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 08:03:21 +0900 Subject: [PATCH 166/166] docs(score): trace same-song snapshot freshness --- docs/traceability/score-attachment-project-context.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/traceability/score-attachment-project-context.md b/docs/traceability/score-attachment-project-context.md index 42871945a..84b0a73b9 100644 --- a/docs/traceability/score-attachment-project-context.md +++ b/docs/traceability/score-attachment-project-context.md @@ -13,6 +13,8 @@ The first context-key repair used a passive React effect to clear the previously A second same-context race remained after that repair. Detach captured the render-time `selected` value before awaiting the content receipt and project metadata persistence. A buyer could open the same score while detach was waiting; after metadata detachment was accepted, the stale closure still saw the earlier selection and could leave the now-detached PDF visible in the viewer even while Score Storage deletion proceeded. +A third same-context race remained in the metadata payload itself. Attach and detach captured render-time `song` and `scoreAttachments` values before awaiting native publication or receipt lookup. If the same `(projectId, song.id)` rerendered with newer visible song metadata while either operation was pending, the continuation could submit the older aggregate to `onSongUpdate`, overwriting the newer visible title, attachments, or other song fields. Project Persistence CAS is still required for durable multi-writer arbitration, but ScoreView must not knowingly submit a stale prop snapshot after its own asynchronous wait. + ## Decision ScoreView derives a context key from `(projectId, song.id)` and keeps the current key in a render-time ref. Every async read, attach and detach captures the key at operation start and rechecks it before any later UI mutation or lifecycle step. @@ -21,6 +23,8 @@ A project/song change invalidates the visible score selection, PDF bytes, read r Score selection also has a live ref synchronized with each selection transition. Detach consults that ref only after durable metadata acceptance. If the attachment being detached became selected while receipt lookup or metadata persistence was in flight, detach invalidates the read generation and clears selection, bytes and opening state before storage deletion. The decision is therefore based on the current same-context viewer state, not the render that initiated detach. +The current rendered song is likewise retained in a live ref. After native attachment publication or receipt lookup returns and the context still matches, ScoreView constructs the metadata proposal from that latest same-song snapshot and its latest `scoreAttachments`, not from the render that started the asynchronous operation. This is a UI/application freshness guard only; Project Persistence remains responsible for durable revision/CAS rejection when another writer changes the project outside the latest rendered snapshot. + Attach rechecks context after native publication and again after project metadata acceptance. If native publication completed for the old project after navigation, ScoreView does not attach that result to the new project and does not delete the bytes; the object remains a recovery candidate for higher-level reconciliation. Detach rechecks context after receipt acquisition and again after project metadata acceptance. It therefore never invokes receipt-bound Score Storage deletion from an intent that crossed a project switch. Project Persistence remains owner of durable active-project identity and CAS semantics; this UI guard does not replace #970. @@ -33,6 +37,10 @@ Detach rechecks context after receipt acquisition and again after project metada `721bddc70081329c860c267cb3b83ce73531a3c8` adds the same-context selection RED: remove begins while receipt lookup is pending, the buyer opens that score, metadata detachment is then accepted, and the viewer must no longer display the detached score. The previous closure-based `selected` check retained the bytes. `9f63bd53db01949670f35a7ed8cdf7cee46bc10e` repairs the cause by tracking live selection identity and clearing the current detached selection after metadata acceptance. The RED head did not receive terminal hosted evidence before the repair descendant, so no hosted RED is claimed. +`8dd038be0f115377cda7071e2d49a909effb3013` adds same-song snapshot RED coverage for both directions. One case starts native attachment publication, rerenders the same song with a newer title and a concurrent attachment, then requires the accepted proposal to retain that newer snapshot plus the newly published score. The other starts detach receipt lookup, rerenders the same song with newer metadata and another attachment, then requires the proposal to remove only the targeted score from the newer snapshot. The prior closure-based `song`/`attachments` payload loses those concurrent visible updates. + +`8529ec09254ae5c6c27d92454a1ae8a02629c27b` repairs that cause by tracking the latest rendered same-song aggregate and deriving attach/detach proposals from it only after context revalidation. No Score Storage filesystem authority moves into the UI, and no durable CAS claim is added. The RED head did not receive terminal hosted evidence before the repair descendant, so no hosted RED is claimed. + The focused regressions are owned by `score-storage-native` through the Ubuntu UI job. Hosted GREEN belongs only to an unchanged exact current head; predecessor native/UI verdicts do not transfer across this source change. ## Invariants and safe failure @@ -43,9 +51,10 @@ The focused regressions are owned by `score-storage-native` through the Ubuntu U - Old-project detach receipt completion never starts metadata mutation for the new context. - If navigation occurs while detach metadata persistence is pending, Score Storage deletion is suppressed after the persistence result returns. - If the score being detached becomes selected while same-context detach is in flight, accepted metadata detachment invalidates that current selection and its bytes before storage deletion. +- Same-song attach/detach continuation derives its metadata proposal from the latest rendered song snapshot and preserves unrelated newer visible attachment metadata. - A stale operation does not manufacture compensating deletion authority. Published bytes remain recoverable rather than being guessed away. - Same-project metadata rerenders do not invalidate work because the key is based on project id and song id, not object identity. ## Claim boundary -This closes the in-component asynchronous project-switch continuation, pre-paint stale-score reset, and same-context detach-selection freshness boundaries for ScoreView. The focused jsdom regressions prove the state/lifecycle authority cases; the pre-paint timing claim follows the React client rendering contract and the use of `useLayoutEffect`, not a synthetic browser-paint timer. It does not claim atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Those remain separate acceptance work. \ No newline at end of file +This closes the in-component asynchronous project-switch continuation, pre-paint stale-score reset, same-context detach-selection freshness, and latest-rendered same-song payload freshness boundaries for ScoreView. The focused jsdom regressions prove the state/lifecycle authority cases; the pre-paint timing claim follows the React client rendering contract and the use of `useLayoutEffect`, not a synthetic browser-paint timer. It does not claim durable conflict freedom when another writer changes Project Persistence outside the rendered snapshot, atomicity across Project Persistence and Score Storage, shipped Tauri cancellation semantics, project deletion/recovery rollback, restart Recover/Preserve/Discard authorization, or old-build coexistence. Durable revision/CAS remains #970 acceptance work. \ No newline at end of file