From 29acea4a15b5999a04af44d7f9887eac24cc2e26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:49:07 -0700 Subject: [PATCH 01/23] test(project): require no-clobber staged publication --- .../src-tauri/src/project_persistence.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/desktop/src-tauri/src/project_persistence.rs diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs new file mode 100644 index 000000000..a19a7b515 --- /dev/null +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -0,0 +1,50 @@ +#[cfg(test)] +mod tests { + use super::publish_new_project_file; + use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + + fn test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bandscope-project-persistence-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory should be created"); + path + } + + #[test] + fn publishes_complete_new_project_without_stage_artifacts() { + let root = test_dir("new"); + let target = root.join("setlist.bscope"); + let content = br#"{\"id\":\"song-1\"}"#; + + publish_new_project_file(&target, content).expect("new project should publish safely"); + + assert_eq!(fs::read(&target).expect("published project should be readable"), content); + let names = fs::read_dir(&root) + .expect("test directory should be readable") + .map(|entry| entry.expect("directory entry should be readable").file_name()) + .collect::>(); + assert_eq!(names, vec![target.file_name().unwrap().to_os_string()]); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn refuses_to_clobber_an_existing_known_good_project() { + let root = test_dir("existing"); + let target = root.join("setlist.bscope"); + let known_good = br#"{\"id\":\"known-good\"}"#; + fs::write(&target, known_good).expect("fixture should be written"); + + let error = publish_new_project_file(&target, br#"{\"id\":\"replacement\"}"#) + .expect_err("existing project must not be overwritten unsafely"); + + assert_eq!(error, "Project file already exists. Choose a new file name."); + assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); + fs::remove_dir_all(root).expect("test directory should be removable"); + } +} From e673356618c26cb6bd2ecacab49f9b17c6c0bcb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:51:49 -0700 Subject: [PATCH 02/23] test(project): wire no-clobber publication regression --- apps/desktop/src-tauri/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..bcc56ceca 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod project_persistence; + use bandscope_desktop_core::*; use rfd::FileDialog; use serde_json::{json, Value}; From f4761810ce6a433ac2cb14ed2ad44794d728c552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:53:03 -0700 Subject: [PATCH 03/23] fix(project): stage and publish new saves without clobber --- .../src-tauri/src/project_persistence.rs | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a19a7b515..dd8c58311 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1,7 +1,72 @@ +use std::{ + ffi::OsString, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, +}; + +const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; +const PROJECT_EXISTS_ERROR: &str = "Project file already exists. Choose a new file name."; +const PROJECT_STAGE_ERROR: &str = "Could not stage the project safely."; +const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; + +fn staging_path(target: &Path) -> Result { + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; + let mut stage_name = OsString::from("."); + stage_name.push(file_name); + stage_name.push(format!(".{}.stage", uuid::Uuid::new_v4())); + Ok(parent.join(stage_name)) +} + +fn remove_stage(path: &Path) { + let _ = fs::remove_file(path); +} + +/// Publishes one new project only after its complete bounded bytes are staged and synced. +/// +/// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the +/// staging name non-clobbering, and `hard_link` atomically creates the user-selected destination +/// only if that destination is still absent. An existing file or dangling symlink therefore stays +/// untouched instead of being truncated before replacement bytes are durable. Crash-safe overwrite, +/// backup rotation, migration, and recovery remain separate project-format work under #962. +pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { + if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + + let stage = staging_path(target)?; + let mut staged = File::create_new(&stage).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if staged.write_all(content).is_err() || staged.sync_all().is_err() { + drop(staged); + remove_stage(&stage); + return Err(PROJECT_STAGE_ERROR.to_string()); + } + drop(staged); + + if let Err(error) = fs::hard_link(&stage, target) { + remove_stage(&stage); + return if error.kind() == std::io::ErrorKind::AlreadyExists { + Err(PROJECT_EXISTS_ERROR.to_string()) + } else { + Err(PROJECT_PUBLISH_ERROR.to_string()) + }; + } + + // Both names reference the already-synced inode at this point. Cleanup failure does not make the + // published target partial, so do not report a false save failure after publication succeeded. + remove_stage(&stage); + Ok(()) +} + #[cfg(test)] mod tests { - use super::publish_new_project_file; - use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + use super::{publish_new_project_file, MAX_PROJECT_FILE_BYTES}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; fn test_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -47,4 +112,19 @@ mod tests { assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); fs::remove_dir_all(root).expect("test directory should be removable"); } + + #[test] + fn rejects_project_bytes_beyond_the_existing_load_limit_before_staging() { + let root = test_dir("oversize"); + let target = root.join("setlist.bscope"); + let content = vec![b'x'; MAX_PROJECT_FILE_BYTES + 1]; + + let error = publish_new_project_file(&target, &content) + .expect_err("oversized project should fail before publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!target.exists()); + assert_eq!(fs::read_dir(&root).expect("directory should be readable").count(), 0); + fs::remove_dir_all(root).expect("test directory should be removable"); + } } From 18d5812024374210b4e075e36a1d46db37403f49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:10:44 -0700 Subject: [PATCH 04/23] test(project): require safe save publication wiring --- apps/desktop/src-tauri/src/project_persistence.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index dd8c58311..942b863fb 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -127,4 +127,18 @@ mod tests { assert_eq!(fs::read_dir(&root).expect("directory should be readable").count(), 0); fs::remove_dir_all(root).expect("test directory should be removable"); } + + #[test] + fn save_project_command_routes_through_safe_publisher() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::publish_new_project_file"), + "the Tauri save command must use the staged non-clobbering publisher" + ); + assert!( + !main_source.contains("std::fs::write(path, content)"), + "the Tauri save command must not truncate the selected destination directly" + ); + } } From 200eac3cddcbbdc3c7ea1dbfa0c81d54cf9f2cdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:12:15 -0700 Subject: [PATCH 05/23] fix(project): publish saves without clobbering existing files --- apps/desktop/src-tauri/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index bcc56ceca..984243039 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -752,7 +752,7 @@ fn save_project(payload: Value) -> Result<(), String> { let content = serde_json::to_string_pretty(&parsed) .map_err(|_| "Failed to serialize project".to_string())?; - std::fs::write(path, content).map_err(|_| "Failed to write file".to_string())?; + project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) } From 1ecb7c495299ab3486e14d7e2c076c7e3b3edaa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:50:11 -0700 Subject: [PATCH 06/23] test(project): require bounded project reads --- .../src-tauri/src/project_persistence.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 942b863fb..93e091f05 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -61,7 +61,7 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< #[cfg(test)] mod tests { - use super::{publish_new_project_file, MAX_PROJECT_FILE_BYTES}; + use super::{publish_new_project_file, read_project_file, MAX_PROJECT_FILE_BYTES}; use std::{ fs, path::PathBuf, @@ -128,6 +128,36 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[test] + fn reads_project_content_within_the_existing_load_limit() { + let root = test_dir("read-valid"); + let target = root.join("setlist.bscope"); + let content = r#"{"id":"song-1"}"#; + fs::write(&target, content).expect("fixture should be written"); + + assert_eq!( + read_project_file(&target).expect("bounded project should be readable"), + content + ); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[test] + fn rejects_oversized_project_during_the_read_itself() { + let root = test_dir("read-oversize"); + let target = root.join("setlist.bscope"); + let file = File::create(&target).expect("fixture should be created"); + file.set_len((MAX_PROJECT_FILE_BYTES + 1) as u64) + .expect("sparse oversize fixture should be sized"); + drop(file); + + let error = read_project_file(&target) + .expect_err("the project reader must enforce the byte ceiling while reading"); + + assert_eq!(error, "Project file is too large (exceeds 5MB limit)"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + #[test] fn save_project_command_routes_through_safe_publisher() { let main_source = include_str!("main.rs"); @@ -141,4 +171,18 @@ mod tests { "the Tauri save command must not truncate the selected destination directly" ); } + + #[test] + fn load_project_command_routes_through_bounded_reader() { + let main_source = include_str!("main.rs"); + + assert!( + main_source.contains("project_persistence::read_project_file(&path)"), + "the Tauri load command must enforce the byte ceiling while reading" + ); + assert!( + !main_source.contains("std::fs::read_to_string(path)"), + "the Tauri load command must not allocate through an unbounded second read" + ); + } } From f852054727354094cbc87ee36e3230d863da5cfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:50:48 -0700 Subject: [PATCH 07/23] fix(project): bound project reads at the file boundary --- .../src-tauri/src/project_persistence.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 93e091f05..0b866448a 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -1,7 +1,7 @@ use std::{ ffi::OsString, fs::{self, File}, - io::Write, + io::{Read, Write}, path::{Path, PathBuf}, }; @@ -9,6 +9,8 @@ const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; const PROJECT_EXISTS_ERROR: &str = "Project file already exists. Choose a new file name."; const PROJECT_STAGE_ERROR: &str = "Could not stage the project safely."; const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; +const PROJECT_READ_ERROR: &str = "Failed to read file"; +const PROJECT_TOO_LARGE_ERROR: &str = "Project file is too large (exceeds 5MB limit)"; fn staging_path(target: &Path) -> Result { let parent = target.parent().unwrap_or_else(|| Path::new(".")); @@ -23,6 +25,26 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } +/// Reads one project through the same bounded byte ceiling used by project publication. +/// +/// The file is opened once and the reader itself is capped at `MAX_PROJECT_FILE_BYTES + 1`, so a +/// file that grows after selection cannot turn a metadata preflight into an unbounded allocation. +/// UTF-8 decoding happens only after the bounded read completes. Path selection remains owned by the +/// native file dialog; symlink/handle-level containment and durable project recovery are later #962 +/// boundaries rather than claims of this helper. +pub(crate) fn read_project_file(target: &Path) -> Result { + let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|_| PROJECT_READ_ERROR.to_string())?; + if bytes.len() > MAX_PROJECT_FILE_BYTES { + return Err(PROJECT_TOO_LARGE_ERROR.to_string()); + } + String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) +} + /// Publishes one new project only after its complete bounded bytes are staged and synced. /// /// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the @@ -146,7 +168,7 @@ mod tests { fn rejects_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); let target = root.join("setlist.bscope"); - let file = File::create(&target).expect("fixture should be created"); + let file = fs::File::create(&target).expect("fixture should be created"); file.set_len((MAX_PROJECT_FILE_BYTES + 1) as u64) .expect("sparse oversize fixture should be sized"); drop(file); From eae423c44fb39b89c33d534d65f47ab6552a1cea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:53:14 -0700 Subject: [PATCH 08/23] fix(project): route load through bounded reader --- apps/desktop/src-tauri/src/main.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 984243039..0c78506be 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -764,12 +764,7 @@ fn load_project() -> Result { .pick_file() .ok_or_else(|| "User cancelled".to_string())?; - let metadata = std::fs::metadata(&path).map_err(|_| "Failed to read file".to_string())?; - if metadata.len() > 5 * 1024 * 1024 { - return Err("Project file is too large (exceeds 5MB limit)".to_string()); - } - - let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?; + let content = project_persistence::read_project_file(&path)?; project_payload_from_content(&content) } From f99d41f83a723f7256ae9993da041f8d9584fe20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:53:46 -0700 Subject: [PATCH 09/23] docs(changelog): record bounded project persistence --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..7fd9fda87 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 + +- Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. + ## [0.1.3] - 2026-04-29 ### Fixed From 935bfa821f350faba3b08346d94329159c396113 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:41:31 -0700 Subject: [PATCH 10/23] test(project): reject symlink project reads --- .../src-tauri/src/project_persistence.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 0b866448a..1ce4a4d19 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -164,6 +164,24 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[cfg(unix)] + #[test] + fn rejects_project_symlink_before_reading_external_content() { + use std::os::unix::fs::symlink; + + let root = test_dir("read-symlink"); + let external = root.join("external.json"); + let selected = root.join("selected.bscope"); + fs::write(&external, r#"{"id":"external"}"#).expect("external fixture should be written"); + symlink(&external, &selected).expect("fixture symlink should be created"); + + let error = read_project_file(&selected) + .expect_err("a selected symlink must not redirect the project reader"); + + assert_eq!(error, "Failed to read file"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + #[test] fn rejects_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); From 20767156c10158863a8a320aeffa92c18843aebb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:42:05 -0700 Subject: [PATCH 11/23] fix(project): reject directly selected symlink loads --- .../desktop/src-tauri/src/project_persistence.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 1ce4a4d19..3ad7da562 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -27,12 +27,18 @@ fn remove_stage(path: &Path) { /// Reads one project through the same bounded byte ceiling used by project publication. /// -/// The file is opened once and the reader itself is capped at `MAX_PROJECT_FILE_BYTES + 1`, so a -/// file that grows after selection cannot turn a metadata preflight into an unbounded allocation. -/// UTF-8 decoding happens only after the bounded read completes. Path selection remains owned by the -/// native file dialog; symlink/handle-level containment and durable project recovery are later #962 -/// boundaries rather than claims of this helper. +/// A directly selected symlink is rejected before it can redirect the read to different content. +/// The regular file is then opened once and the reader itself is capped at +/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata +/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read +/// completes. Handle-level identity checks for a path swapped between inspection and open remain a +/// later #962 boundary and are not claimed by this helper. pub(crate) fn read_project_file(target: &Path) -> Result { + let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if metadata.file_type().is_symlink() { + return Err(PROJECT_READ_ERROR.to_string()); + } + let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); From d38862c3dd6bf789b907196a086ead1e08636065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:42:34 -0700 Subject: [PATCH 12/23] docs(changelog): record symlink-safe project loading --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd9fda87..176cc70f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. +- Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. ## [0.1.3] - 2026-04-29 From ad1d79198e7909c046569a6197718f933907b6d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:09:52 -0700 Subject: [PATCH 13/23] test(project): prove load TOCTOU path swap --- .../src-tauri/src/project_persistence.rs | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 3ad7da562..7f620abb3 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -25,21 +25,16 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } -/// Reads one project through the same bounded byte ceiling used by project publication. -/// -/// A directly selected symlink is rejected before it can redirect the read to different content. -/// The regular file is then opened once and the reader itself is capped at -/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata -/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read -/// completes. Handle-level identity checks for a path swapped between inspection and open remain a -/// later #962 boundary and are not claimed by this helper. -pub(crate) fn read_project_file(target: &Path) -> Result { +fn read_project_file_with_opener(target: &Path, open_file: F) -> Result +where + F: FnOnce(&Path) -> std::io::Result, +{ let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; if metadata.file_type().is_symlink() { return Err(PROJECT_READ_ERROR.to_string()); } - let file = File::open(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); reader @@ -51,6 +46,18 @@ pub(crate) fn read_project_file(target: &Path) -> Result { String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) } +/// Reads one project through the same bounded byte ceiling used by project publication. +/// +/// A directly selected symlink is rejected before it can redirect the read to different content. +/// The regular file is then opened once and the reader itself is capped at +/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata +/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read +/// completes. Handle-level identity checks for a path swapped between inspection and open remain a +/// later #962 boundary and are not claimed by this helper. +pub(crate) fn read_project_file(target: &Path) -> Result { + read_project_file_with_opener(target, File::open) +} + /// Publishes one new project only after its complete bounded bytes are staged and synced. /// /// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the @@ -89,7 +96,10 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< #[cfg(test)] mod tests { - use super::{publish_new_project_file, read_project_file, MAX_PROJECT_FILE_BYTES}; + use super::{ + publish_new_project_file, read_project_file, read_project_file_with_opener, + MAX_PROJECT_FILE_BYTES, + }; use std::{ fs, path::PathBuf, @@ -188,6 +198,27 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[test] + fn rejects_project_replaced_between_preflight_and_open() { + let root = test_dir("read-swap"); + let selected = root.join("selected.bscope"); + let replacement = root.join("replacement.bscope"); + let parked = root.join("parked.bscope"); + fs::write(&selected, r#"{"id":"selected"}"#).expect("selected fixture should be written"); + fs::write(&replacement, r#"{"id":"replacement-with-different-bytes"}"#) + .expect("replacement fixture should be written"); + + let error = read_project_file_with_opener(&selected, |path| { + fs::rename(path, &parked)?; + fs::rename(&replacement, path)?; + fs::File::open(path) + }) + .expect_err("a path replacement between preflight and open must fail closed"); + + assert_eq!(error, "Failed to read file"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + #[test] fn rejects_oversized_project_during_the_read_itself() { let root = test_dir("read-oversize"); From 6cc163cca1a92178472d7e2688282b336a5a0595 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:11:00 -0700 Subject: [PATCH 14/23] fix(project): bind load preflight to opened file --- .../src-tauri/src/project_persistence.rs | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 7f620abb3..c12058629 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -12,6 +12,11 @@ const PROJECT_PUBLISH_ERROR: &str = "Could not publish the project safely."; const PROJECT_READ_ERROR: &str = "Failed to read file"; const PROJECT_TOO_LARGE_ERROR: &str = "Project file is too large (exceeds 5MB limit)"; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + fn staging_path(target: &Path) -> Result { let parent = target.parent().unwrap_or_else(|| Path::new(".")); let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; @@ -25,16 +30,76 @@ fn remove_stage(path: &Path) { let _ = fs::remove_file(path); } +#[cfg(windows)] +fn open_project_file(target: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + options.open(target) +} + +#[cfg(not(windows))] +fn open_project_file(target: &Path) -> std::io::Result { + File::open(target) +} + +#[cfg(unix)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(windows)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + left.file_attributes() == right.file_attributes() + && left.creation_time() == right.creation_time() + && left.last_write_time() == right.last_write_time() + && left.file_size() == right.file_size() +} + +#[cfg(not(any(unix, windows)))] +fn same_file_identity(_left: &fs::Metadata, _right: &fs::Metadata) -> bool { + false +} + +#[cfg(windows)] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_file() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { + metadata.is_file() && !metadata.file_type().is_symlink() +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, { - let metadata = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; - if metadata.file_type().is_symlink() { + let before = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&before) { return Err(PROJECT_READ_ERROR.to_string()); } let file = open_file(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + let opened = file.metadata().map_err(|_| PROJECT_READ_ERROR.to_string())?; + let after = fs::symlink_metadata(target).map_err(|_| PROJECT_READ_ERROR.to_string())?; + if !metadata_is_regular_project_file(&opened) + || !metadata_is_regular_project_file(&after) + || !same_file_identity(&before, &opened) + || !same_file_identity(&opened, &after) + { + return Err(PROJECT_READ_ERROR.to_string()); + } + let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); let mut bytes = Vec::new(); reader @@ -46,16 +111,17 @@ where String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) } -/// Reads one project through the same bounded byte ceiling used by project publication. +/// Reads one project through a bounded, path-stable native file handle. /// -/// A directly selected symlink is rejected before it can redirect the read to different content. -/// The regular file is then opened once and the reader itself is capped at -/// `MAX_PROJECT_FILE_BYTES + 1`, so a file that grows after selection cannot turn a metadata -/// preflight into an unbounded allocation. UTF-8 decoding happens only after the bounded read -/// completes. Handle-level identity checks for a path swapped between inspection and open remain a -/// later #962 boundary and are not claimed by this helper. +/// The selected path must name the same regular file before the open, on the opened handle, and +/// immediately after the open. Unix builds compare device/inode identity. Windows opens the reparse +/// point itself rather than following it and rejects reparse handles, then requires the stable file +/// metadata revision to match around the open. This closes the selected-path swap between the +/// preflight and handle acquisition without adding a dependency or granting JavaScript path +/// authority. The reader remains capped at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, and +/// recovery semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { - read_project_file_with_opener(target, File::open) + read_project_file_with_opener(target, open_project_file) } /// Publishes one new project only after its complete bounded bytes are staged and synced. From aec9c4ee335ae73dc16972ed3b265800a8f6e78b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:11:40 -0700 Subject: [PATCH 15/23] docs(changelog): record project load identity guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 176cc70f4..fcb50b290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. - Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. +- Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows also opens reparse points without following them before validation. ## [0.1.3] - 2026-04-29 From 33da8db18308b213ca31d0f2e1d5b9a79292bcc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:32:56 -0700 Subject: [PATCH 16/23] test(project): reject symlinked save parent --- .../project_persistence_parent_symlink.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs new file mode 100644 index 000000000..f6de3f8c0 --- /dev/null +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -0,0 +1,44 @@ +#[path = "../src/project_persistence.rs"] +mod project_persistence; + +#[cfg(unix)] +#[test] +fn refuses_to_publish_through_symlinked_parent_directory() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-persistence-parent-symlink-{}-{nonce}", + std::process::id() + )); + let external = root.join("external"); + let linked_parent = root.join("selected-parent"); + fs::create_dir_all(&external).expect("external fixture directory should be created"); + symlink(&external, &linked_parent).expect("fixture parent symlink should be created"); + + let target = linked_parent.join("setlist.bscope"); + let error = project_persistence::publish_new_project_file( + &target, + br#"{\"id\":\"must-not-escape\"}"#, + ) + .expect_err("a symlinked save parent must not redirect project publication"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!external.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&external) + .expect("external fixture directory should remain readable") + .count(), + 0, + "no staging or published artifact may escape through the symlinked parent" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} From 3bc0114446ac42ebba3b8fb70e02d35c3e94c456 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:34:18 -0700 Subject: [PATCH 17/23] fix(project): reject symlinked save parent --- .../src-tauri/src/project_persistence.rs | 79 +++++++++++++++---- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index c12058629..37fc34928 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -17,9 +17,18 @@ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; #[cfg(windows)] const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +fn project_parent(target: &Path) -> &Path { + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + fn staging_path(target: &Path) -> Result { - let parent = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target.file_name().ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; + let parent = project_parent(target); + let file_name = target + .file_name() + .ok_or_else(|| PROJECT_PUBLISH_ERROR.to_string())?; let mut stage_name = OsString::from("."); stage_name.push(file_name); stage_name.push(format!(".{}.stage", uuid::Uuid::new_v4())); @@ -80,6 +89,18 @@ fn metadata_is_regular_project_file(metadata: &fs::Metadata) -> bool { metadata.is_file() && !metadata.file_type().is_symlink() } +#[cfg(windows)] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +#[cfg(not(windows))] +fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { + metadata.is_dir() && !metadata.file_type().is_symlink() +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -126,16 +147,25 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// Publishes one new project only after its complete bounded bytes are staged and synced. /// -/// This helper deliberately does not implement overwrite semantics. `File::create_new` makes the -/// staging name non-clobbering, and `hard_link` atomically creates the user-selected destination -/// only if that destination is still absent. An existing file or dangling symlink therefore stays -/// untouched instead of being truncated before replacement bytes are durable. Crash-safe overwrite, -/// backup rotation, migration, and recovery remain separate project-format work under #962. +/// This helper deliberately does not implement overwrite semantics. The directly selected parent +/// must itself be a real directory rather than a symlink/reparse point before any staging artifact is +/// created. `File::create_new` makes the staging name non-clobbering, and `hard_link` atomically +/// creates the user-selected destination only if that destination is still absent. An existing file +/// or dangling symlink therefore stays untouched instead of being truncated before replacement bytes +/// are durable. Crash-safe overwrite, ancestor-handle binding, parent-directory durability, backup +/// rotation, migration, and recovery remain separate project-format work under #962. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { if content.is_empty() || content.len() > MAX_PROJECT_FILE_BYTES { return Err(PROJECT_STAGE_ERROR.to_string()); } + let parent = project_parent(target); + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if !metadata_is_safe_project_directory(&parent_metadata) { + return Err(PROJECT_STAGE_ERROR.to_string()); + } + let stage = staging_path(target)?; let mut staged = File::create_new(&stage).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; if staged.write_all(content).is_err() || staged.sync_all().is_err() { @@ -193,7 +223,10 @@ mod tests { publish_new_project_file(&target, content).expect("new project should publish safely"); - assert_eq!(fs::read(&target).expect("published project should be readable"), content); + assert_eq!( + fs::read(&target).expect("published project should be readable"), + content + ); let names = fs::read_dir(&root) .expect("test directory should be readable") .map(|entry| entry.expect("directory entry should be readable").file_name()) @@ -212,8 +245,14 @@ mod tests { let error = publish_new_project_file(&target, br#"{\"id\":\"replacement\"}"#) .expect_err("existing project must not be overwritten unsafely"); - assert_eq!(error, "Project file already exists. Choose a new file name."); - assert_eq!(fs::read(&target).expect("known-good project should remain"), known_good); + assert_eq!( + error, + "Project file already exists. Choose a new file name." + ); + assert_eq!( + fs::read(&target).expect("known-good project should remain"), + known_good + ); fs::remove_dir_all(root).expect("test directory should be removable"); } @@ -228,7 +267,12 @@ mod tests { assert_eq!(error, "Could not stage the project safely."); assert!(!target.exists()); - assert_eq!(fs::read_dir(&root).expect("directory should be readable").count(), 0); + assert_eq!( + fs::read_dir(&root) + .expect("directory should be readable") + .count(), + 0 + ); fs::remove_dir_all(root).expect("test directory should be removable"); } @@ -254,7 +298,8 @@ mod tests { let root = test_dir("read-symlink"); let external = root.join("external.json"); let selected = root.join("selected.bscope"); - fs::write(&external, r#"{"id":"external"}"#).expect("external fixture should be written"); + fs::write(&external, r#"{"id":"external"}"#) + .expect("external fixture should be written"); symlink(&external, &selected).expect("fixture symlink should be created"); let error = read_project_file(&selected) @@ -270,9 +315,13 @@ mod tests { let selected = root.join("selected.bscope"); let replacement = root.join("replacement.bscope"); let parked = root.join("parked.bscope"); - fs::write(&selected, r#"{"id":"selected"}"#).expect("selected fixture should be written"); - fs::write(&replacement, r#"{"id":"replacement-with-different-bytes"}"#) - .expect("replacement fixture should be written"); + fs::write(&selected, r#"{"id":"selected"}"#) + .expect("selected fixture should be written"); + fs::write( + &replacement, + r#"{"id":"replacement-with-different-bytes"}"#, + ) + .expect("replacement fixture should be written"); let error = read_project_file_with_opener(&selected, |path| { fs::rename(path, &parked)?; From f08dd97bde79edcc18e119a2464010c366a6e62d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:34:50 -0700 Subject: [PATCH 18/23] docs(project): record save-parent trust boundary --- CHANGELOG.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb50b290..1cd8f0112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. - Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. +- Reject a symlinked/reparse-point save parent before staging so a selected project path cannot redirect new project publication into a different directory. - Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows also opens reparse points without following them before validation. ## [0.1.3] - 2026-04-29 @@ -58,17 +59,3 @@ - Issue #33: Implemented secure local audio intake and project bootstrap - Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 39272d237a219a3a491be63c9aa11538eff58b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:42:11 -0700 Subject: [PATCH 19/23] test(project): reject symlinked save ancestors --- .../project_persistence_parent_symlink.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index f6de3f8c0..48ee58543 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -42,3 +42,48 @@ fn refuses_to_publish_through_symlinked_parent_directory() { fs::remove_dir_all(root).expect("test fixture should be removable"); } + +#[cfg(unix)] +#[test] +fn refuses_to_publish_when_an_ancestor_directory_is_a_symlink() { + use std::{ + fs, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-project-persistence-ancestor-symlink-{}-{nonce}", + std::process::id() + )); + let actual_tree = root.join("actual"); + let actual_parent = actual_tree.join("nested"); + let selected_tree = root.join("selected"); + let linked_ancestor = selected_tree.join("redirect"); + fs::create_dir_all(&actual_parent).expect("actual fixture tree should be created"); + fs::create_dir_all(&selected_tree).expect("selected fixture tree should be created"); + symlink(&actual_tree, &linked_ancestor).expect("fixture ancestor symlink should be created"); + + let target = linked_ancestor.join("nested").join("setlist.bscope"); + let error = project_persistence::publish_new_project_file( + &target, + br#"{\"id\":\"ancestor-symlink\"}"#, + ) + .expect_err("a symlinked save ancestor must be rejected before staging"); + + assert_eq!(error, "Could not stage the project safely."); + assert!(!actual_parent.join("setlist.bscope").exists()); + assert_eq!( + fs::read_dir(&actual_parent) + .expect("actual fixture directory should remain readable") + .count(), + 0, + "the selected path must not publish through a symlinked ancestor" + ); + + fs::remove_dir_all(root).expect("test fixture should be removable"); +} From 2f6afc9e7a846c1de26ce79a507fc4226aab90e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:44:41 -0700 Subject: [PATCH 20/23] fix(project): validate save parent chain --- apps/desktop/src-tauri/src/project_persistence.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 37fc34928..03437b4d5 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -101,6 +101,16 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } +fn parent_chain_is_safe(parent: &Path) -> bool { + parent + .ancestors() + .filter(|path| !path.as_os_str().is_empty()) + .all(|path| { + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata_is_safe_project_directory(&metadata)) + }) +} + fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -160,9 +170,7 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< } let parent = project_parent(target); - let parent_metadata = - fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; - if !metadata_is_safe_project_directory(&parent_metadata) { + if !parent_chain_is_safe(parent) { return Err(PROJECT_STAGE_ERROR.to_string()); } From 49c002ca41217dd22d1155b21d86c766d1efd161 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:45:52 -0700 Subject: [PATCH 21/23] test(project): keep portable parent-link boundary --- .../project_persistence_parent_symlink.rs | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs index 48ee58543..f6de3f8c0 100644 --- a/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs +++ b/apps/desktop/src-tauri/tests/project_persistence_parent_symlink.rs @@ -42,48 +42,3 @@ fn refuses_to_publish_through_symlinked_parent_directory() { fs::remove_dir_all(root).expect("test fixture should be removable"); } - -#[cfg(unix)] -#[test] -fn refuses_to_publish_when_an_ancestor_directory_is_a_symlink() { - use std::{ - fs, - os::unix::fs::symlink, - time::{SystemTime, UNIX_EPOCH}, - }; - - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after Unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "bandscope-project-persistence-ancestor-symlink-{}-{nonce}", - std::process::id() - )); - let actual_tree = root.join("actual"); - let actual_parent = actual_tree.join("nested"); - let selected_tree = root.join("selected"); - let linked_ancestor = selected_tree.join("redirect"); - fs::create_dir_all(&actual_parent).expect("actual fixture tree should be created"); - fs::create_dir_all(&selected_tree).expect("selected fixture tree should be created"); - symlink(&actual_tree, &linked_ancestor).expect("fixture ancestor symlink should be created"); - - let target = linked_ancestor.join("nested").join("setlist.bscope"); - let error = project_persistence::publish_new_project_file( - &target, - br#"{\"id\":\"ancestor-symlink\"}"#, - ) - .expect_err("a symlinked save ancestor must be rejected before staging"); - - assert_eq!(error, "Could not stage the project safely."); - assert!(!actual_parent.join("setlist.bscope").exists()); - assert_eq!( - fs::read_dir(&actual_parent) - .expect("actual fixture directory should remain readable") - .count(), - 0, - "the selected path must not publish through a symlinked ancestor" - ); - - fs::remove_dir_all(root).expect("test fixture should be removable"); -} From 2e48e59916c8fb4552e26c26c8a6c65946f6b7fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:46:52 -0700 Subject: [PATCH 22/23] fix(project): preserve portable parent validation --- apps/desktop/src-tauri/src/project_persistence.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 03437b4d5..37fc34928 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -101,16 +101,6 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { metadata.is_dir() && !metadata.file_type().is_symlink() } -fn parent_chain_is_safe(parent: &Path) -> bool { - parent - .ancestors() - .filter(|path| !path.as_os_str().is_empty()) - .all(|path| { - fs::symlink_metadata(path) - .is_ok_and(|metadata| metadata_is_safe_project_directory(&metadata)) - }) -} - fn read_project_file_with_opener(target: &Path, open_file: F) -> Result where F: FnOnce(&Path) -> std::io::Result, @@ -170,7 +160,9 @@ pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result< } let parent = project_parent(target); - if !parent_chain_is_safe(parent) { + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| PROJECT_STAGE_ERROR.to_string())?; + if !metadata_is_safe_project_directory(&parent_metadata) { return Err(PROJECT_STAGE_ERROR.to_string()); } From 0c991df26abaef4abc21a245ce041bc91ed421db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:16:07 -0700 Subject: [PATCH 23/23] docs(changelog): preserve protected release history --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd8f0112..815c784b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. + ### Fixed +- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. - Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation. - Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content. - Reject a symlinked/reparse-point save parent before staging so a selected project path cannot redirect new project publication into a different directory. @@ -59,3 +64,17 @@ - Issue #33: Implemented secure local audio intake and project bootstrap - Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file