From 578f94b873a5f6c62b9efc0d5707b21622feebb0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 00:53:58 +0900 Subject: [PATCH 1/4] fix(project): recover interrupted publications --- CHANGELOG.md | 3 +- apps/desktop/src-tauri/src/main.rs | 2 + .../src-tauri/src/project_persistence.rs | 297 +++++++++++++++++- 3 files changed, 295 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c950d8a2..c14b4e9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Refuse last-component symlink following during Linux/macOS project handle acquisition and make that acquisition non-blocking so a preflight-to-open path swap cannot redirect the loader or stall it on a special file. - Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path. - Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it. +- Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched. ## [0.1.3] - 2026-04-29 @@ -81,4 +82,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 0c78506be..c870d1e7e 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -752,6 +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())?; + project_persistence::recover_project_publication(&path)?; project_persistence::publish_new_project_file(&path, content.as_bytes())?; Ok(()) @@ -764,6 +765,7 @@ fn load_project() -> Result { .pick_file() .ok_or_else(|| "User cancelled".to_string())?; + project_persistence::recover_project_publication(&path)?; let content = project_persistence::read_project_file(&path)?; project_payload_from_content(&content) } diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 3c4d40a67..b8d35278b 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -3,13 +3,16 @@ use std::{ io::{Read, Write}, path::{Path, PathBuf}, }; +use serde::{Deserialize, Serialize}; const MAX_PROJECT_FILE_BYTES: usize = 5 * 1024 * 1024; +const MAX_RECOVERY_JOURNAL_BYTES: usize = 64 * 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)"; +const PROJECT_RECOVERY_ERROR: &str = "Could not recover the project publication safely."; #[cfg(windows)] const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; @@ -361,7 +364,7 @@ struct WindowsByHandleFileInformation { } #[cfg(windows)] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct WindowsFileIdentity { volume_serial_number: u32, file_index: u64, @@ -420,7 +423,7 @@ fn metadata_is_safe_project_directory(metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct ProjectFileIdentity { device: u64, inode: u64, @@ -469,6 +472,248 @@ pub(crate) fn project_file_identity(_target: &Path) -> Result; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[cfg(windows)] +type JournalPathName = Vec; + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +#[derive(Debug, Deserialize, Serialize)] +struct PublicationJournal { + version: u8, + target_name: JournalPathName, + stage_name: JournalPathName, + expected: ProjectFileIdentity, + candidate: ProjectFileIdentity, +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn journal_path_name(path: &Path) -> Result { + let name = path + .file_name() + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return Ok(name.as_bytes().to_vec()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + return Ok(name.encode_wide().collect()); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn path_from_journal_name(parent: &Path, name: &JournalPathName) -> Option { + #[cfg(unix)] + { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + return Some(parent.join(OsStr::from_bytes(name))); + } + #[cfg(windows)] + { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + return Some(parent.join(OsString::from_wide(name))); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn generated_stage_name(name: &JournalPathName) -> bool { + let Some(path) = path_from_journal_name(Path::new("."), name) else { + return false; + }; + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let Some(uuid) = name + .strip_prefix(".bandscope-stage-") + .and_then(|value| value.strip_suffix(".stage")) + else { + return false; + }; + uuid::Uuid::parse_str(uuid).is_ok() +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recovery_journal_name(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let Some(uuid) = name + .strip_prefix(".bandscope-recovery-") + .and_then(|value| value.strip_suffix(".journal")) + else { + return false; + }; + uuid::Uuid::parse_str(uuid).is_ok() +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(windows)] +fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { + // Windows ReplaceFileW/MoveFileExW provide the native write-through step; directory + // handles are not opened here because ordinary directory opens are not portable on Windows. + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn create_publication_journal( + target: &Path, + stage: &Path, + expected: &ProjectFileIdentity, + candidate: &ProjectFileIdentity, +) -> Result { + let journal_path = project_parent(target).join(format!( + ".bandscope-recovery-{}.journal", + uuid::Uuid::new_v4() + )); + let journal = PublicationJournal { + version: 1, + target_name: journal_path_name(target)?, + stage_name: journal_path_name(stage)?, + expected: expected.clone(), + candidate: candidate.clone(), + }; + let bytes = serde_json::to_vec(&journal).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let mut file = match File::create_new(&journal_path) { + Ok(file) => file, + Err(_) => return Err(PROJECT_RECOVERY_ERROR.to_string()), + }; + if file.write_all(&bytes).is_err() + || file.sync_all().is_err() + || sync_parent_directory(project_parent(target)).is_err() + { + drop(file); + remove_stage(&journal_path); + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + Ok(journal_path) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn project_file_identity_if_present( + path: &Path, +) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + project_file_identity(path).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recover_publication_state( + target: &Path, + journal_path: &Path, + journal: &PublicationJournal, + stage: &Path, +) -> Result<(), String> { + let target_identity = project_file_identity_if_present(target)?; + let stage_identity = project_file_identity_if_present(stage)?; + + if target_identity.as_ref() == Some(&journal.candidate) + && stage_identity.as_ref() == Some(&journal.expected) + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + if rename_exchange(stage, target).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + + #[cfg(windows)] + { + let rollback_stage = staging_path(target)?; + if replace_file_with_backup(target, stage, &rollback_stage).is_err() { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + remove_stage(&rollback_stage); + } + remove_stage(stage); + remove_stage(journal_path); + return Ok(()); + } + + if target_identity.as_ref() == Some(&journal.expected) + && stage_identity.as_ref() == Some(&journal.candidate) + { + remove_stage(stage); + remove_stage(journal_path); + return Ok(()); + } + + if stage_identity.is_none() + && target_identity + .as_ref() + .is_some_and(|identity| identity == &journal.expected || identity == &journal.candidate) + { + remove_stage(journal_path); + return Ok(()); + } + + Err(PROJECT_RECOVERY_ERROR.to_string()) +} + +/// Repairs one durable, adjacent publication journal when its target is selected again. +/// +/// Security Notes: journal and stage names are constrained to generated same-directory names; +/// target, journal, and stage paths must stay regular non-link files; journal reads use the bounded +/// no-follow project reader; mismatched identities fail closed without deleting either file. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { + let parent = project_parent(target); + if !project_parent_chain_is_safe(parent) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let target_name = journal_path_name(target)?; + for entry in fs::read_dir(parent).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())? { + let entry = entry.map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let journal_path = entry.path(); + if !recovery_journal_name(&journal_path) { + continue; + } + let metadata = fs::symlink_metadata(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let content = read_project_file(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if content.len() > MAX_RECOVERY_JOURNAL_BYTES { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let journal: PublicationJournal = + serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if journal.target_name != target_name { + continue; + } + if journal.version != 1 || !generated_stage_name(&journal.stage_name) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let stage = path_from_journal_name(parent, &journal.stage_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + recover_publication_state(target, &journal_path, &journal, &stage)?; + } + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +pub(crate) fn recover_project_publication(_target: &Path) -> Result<(), String> { + Ok(()) +} + #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn replace_existing_project_file( stage: &Path, @@ -476,14 +721,17 @@ pub(crate) fn replace_existing_project_file( expected: &ProjectFileIdentity, ) -> Result<(), String> { let candidate = project_file_identity(stage)?; + let journal = create_publication_journal(target, stage, expected, &candidate)?; if rename_exchange(stage, target).is_err() { remove_stage(stage); + remove_stage(&journal); return Err(PROJECT_PUBLISH_ERROR.to_string()); } let displaced = project_file_identity(stage); if displaced.as_ref().is_ok_and(|identity| identity == expected) { remove_stage(stage); + remove_stage(&journal); return Ok(()); } @@ -491,6 +739,7 @@ pub(crate) fn replace_existing_project_file( project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && rename_exchange(stage, target).is_ok() { remove_stage(stage); + remove_stage(&journal); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -503,14 +752,17 @@ pub(crate) fn replace_existing_project_file( ) -> Result<(), String> { let candidate = project_file_identity(stage)?; let backup = staging_path(target)?; + let journal = create_publication_journal(target, &backup, expected, &candidate)?; if replace_file_with_backup(target, stage, &backup).is_err() { remove_stage(stage); + remove_stage(&journal); return Err(PROJECT_PUBLISH_ERROR.to_string()); } let displaced = project_file_identity(&backup); if displaced.as_ref().is_ok_and(|identity| identity == expected) { remove_stage(&backup); + remove_stage(&journal); return Ok(()); } @@ -518,6 +770,7 @@ pub(crate) fn replace_existing_project_file( project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { remove_stage(stage); + remove_stage(&journal); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -654,7 +907,7 @@ where /// points without following them, rejects reparse handles, and compares the volume serial number plus /// file index returned for native handles before, during, and after acquisition. Other Unix targets /// fail closed until their no-follow open contract is explicitly modeled. The reader remains capped -/// at `MAX_PROJECT_FILE_BYTES + 1`; backup, migration, and recovery semantics remain later #962 work. +/// at `MAX_PROJECT_FILE_BYTES + 1`; backup rotation and migration semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { read_project_file_with_opener(target, open_project_file) } @@ -677,9 +930,9 @@ pub(crate) fn read_project_file(target: &Path) -> Result { /// then uses `renameat2(RENAME_NOREPLACE)`, macOS uses `renamex_np(RENAME_EXCL)`, and Windows uses /// `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` so a concurrently appearing destination is not /// clobbered. Filesystems without the required native primitive fail closed. These checks do not claim -/// descriptor-bound protection for a parent-chain swap, authority before the first post-dialog identity -/// snapshot, or crash recovery if a process is terminated during a mismatch rollback; those remain -/// project-format work under #962. +/// descriptor-bound protection for a parent-chain swap or authority before the first post-dialog +/// identity snapshot. A durable adjacent journal repairs an interrupted mismatch rollback the next +/// time the same target is selected; global startup scanning and backup rotation remain #962 work. pub(crate) fn publish_new_project_file(target: &Path, content: &[u8]) -> Result<(), String> { publish_new_project_file_with_linker(target, content, |source, destination| { fs::hard_link(source, destination) @@ -1013,6 +1266,38 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn recovers_an_interrupted_existing_project_publication() { + let root = test_dir("recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{"id":"known-good"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the next selection should recover the known-good target"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), known_good); + assert!(!stage.exists(), "the interrupted candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + 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"); From 80bbf5f2376756105390d59180ee678ecf412d17 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 00:58:42 +0900 Subject: [PATCH 2/4] fix(project): satisfy persistence lint --- apps/desktop/src-tauri/src/project_persistence.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index b8d35278b..a85e794d7 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -498,12 +498,12 @@ fn journal_path_name(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; - return Ok(name.as_bytes().to_vec()); + Ok(name.as_bytes().to_vec()) } #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; - return Ok(name.encode_wide().collect()); + Ok(name.encode_wide().collect()) } } @@ -512,13 +512,13 @@ fn path_from_journal_name(parent: &Path, name: &JournalPathName) -> Option Date: Sun, 30 Aug 2026 01:12:08 +0900 Subject: [PATCH 3/4] fix(project): make recovery cleanup durable --- .../src-tauri/src/project_persistence.rs | 365 ++++++++++++++---- 1 file changed, 284 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index a85e794d7..7f65e0340 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -485,7 +485,8 @@ type JournalPathName = Vec; struct PublicationJournal { version: u8, target_name: JournalPathName, - stage_name: JournalPathName, + candidate_name: JournalPathName, + displaced_name: JournalPathName, expected: ProjectFileIdentity, candidate: ProjectFileIdentity, } @@ -540,17 +541,32 @@ fn generated_stage_name(name: &JournalPathName) -> bool { } #[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn recovery_journal_name(path: &Path) -> bool { - let Some(name) = path.file_name().and_then(|value| value.to_str()) else { - return false; - }; - let Some(uuid) = name - .strip_prefix(".bandscope-recovery-") - .and_then(|value| value.strip_suffix(".journal")) - else { - return false; - }; - uuid::Uuid::parse_str(uuid).is_ok() +fn journal_target_key(target: &Path) -> Result { + let name = journal_path_name(target)?; + let mut hash = 0xcbf29ce484222325u64; + #[cfg(unix)] + for byte in name { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + #[cfg(windows)] + for unit in name { + for byte in unit.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + Ok(hash) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn publication_journal_path(target: &Path, published: bool) -> Result { + let phase = if published { "published" } else { "prepared" }; + Ok(project_parent(target).join(format!( + ".bandscope-recovery-{:016x}.{}.journal", + journal_target_key(target)?, + phase + ))) } #[cfg(unix)] @@ -568,18 +584,17 @@ fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { #[cfg(any(target_os = "linux", target_os = "macos", windows))] fn create_publication_journal( target: &Path, - stage: &Path, + candidate_stage: &Path, + displaced: &Path, expected: &ProjectFileIdentity, candidate: &ProjectFileIdentity, ) -> Result { - let journal_path = project_parent(target).join(format!( - ".bandscope-recovery-{}.journal", - uuid::Uuid::new_v4() - )); + let journal_path = publication_journal_path(target, false)?; let journal = PublicationJournal { version: 1, target_name: journal_path_name(target)?, - stage_name: journal_path_name(stage)?, + candidate_name: journal_path_name(candidate_stage)?, + displaced_name: journal_path_name(displaced)?, expected: expected.clone(), candidate: candidate.clone(), }; @@ -615,51 +630,150 @@ fn project_file_identity_if_present( } } +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn remove_recovery_artifact(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn recovery_artifact_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(PROJECT_RECOVERY_ERROR.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn promote_publication_journal(prepared: &Path, target: &Path) -> Result { + let published = publication_journal_path(target, true)?; + rename_noreplace(prepared, &published).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + Ok(published) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_successful_publication( + prepared: &Path, + stage: &Path, + target: &Path, +) -> Result<(), String> { + let published = promote_publication_journal(prepared, target)?; + remove_stage(stage); + if matches!( + fs::symlink_metadata(stage), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) && sync_parent_directory(project_parent(target)).is_ok() + { + remove_stage(&published); + let _ = sync_parent_directory(project_parent(target)); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn finish_rolled_back_publication(stage: &Path, journal: &Path, target: &Path) { + if sync_parent_directory(project_parent(target)).is_err() + || remove_recovery_artifact(stage).is_err() + || sync_parent_directory(project_parent(target)).is_err() + || remove_recovery_artifact(journal).is_err() + { + return; + } + let _ = sync_parent_directory(project_parent(target)); +} + #[cfg(any(target_os = "linux", target_os = "macos", windows))] fn recover_publication_state( target: &Path, journal_path: &Path, journal: &PublicationJournal, - stage: &Path, + candidate_stage: &Path, + displaced: &Path, + published: bool, ) -> Result<(), String> { let target_identity = project_file_identity_if_present(target)?; - let stage_identity = project_file_identity_if_present(stage)?; + let candidate_identity = project_file_identity_if_present(candidate_stage)?; + let displaced_identity = if displaced == candidate_stage { + candidate_identity.clone() + } else { + project_file_identity_if_present(displaced)? + }; + + if published { + if target_identity.as_ref() != Some(&journal.candidate) + || (displaced_identity.is_some() + && displaced_identity.as_ref() != Some(&journal.expected)) + || (displaced != candidate_stage && candidate_identity.is_some()) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if displaced_identity.is_some() { + remove_recovery_artifact(displaced)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + } + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + return Ok(()); + } if target_identity.as_ref() == Some(&journal.candidate) - && stage_identity.as_ref() == Some(&journal.expected) + && displaced_identity.as_ref() == Some(&journal.expected) { #[cfg(any(target_os = "linux", target_os = "macos"))] - if rename_exchange(stage, target).is_err() { + if rename_exchange(displaced, target).is_err() { return Err(PROJECT_RECOVERY_ERROR.to_string()); } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; #[cfg(windows)] { - let rollback_stage = staging_path(target)?; - if replace_file_with_backup(target, stage, &rollback_stage).is_err() { + if replace_file_with_backup(target, displaced, candidate_stage).is_err() { return Err(PROJECT_RECOVERY_ERROR.to_string()); } - remove_stage(&rollback_stage); } - remove_stage(stage); - remove_stage(journal_path); + remove_recovery_artifact(candidate_stage)?; + if displaced != candidate_stage { + remove_recovery_artifact(displaced)?; + } + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; return Ok(()); } if target_identity.as_ref() == Some(&journal.expected) - && stage_identity.as_ref() == Some(&journal.candidate) + && candidate_identity.as_ref() == Some(&journal.candidate) + && (displaced_identity.is_none() || displaced == candidate_stage) { - remove_stage(stage); - remove_stage(journal_path); + remove_recovery_artifact(candidate_stage)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; return Ok(()); } - if stage_identity.is_none() + if candidate_identity.is_none() + && displaced_identity.is_none() && target_identity .as_ref() .is_some_and(|identity| identity == &journal.expected || identity == &journal.candidate) { - remove_stage(journal_path); + remove_recovery_artifact(journal_path)?; + sync_parent_directory(project_parent(target)) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; return Ok(()); } @@ -668,9 +782,10 @@ fn recover_publication_state( /// Repairs one durable, adjacent publication journal when its target is selected again. /// -/// Security Notes: journal and stage names are constrained to generated same-directory names; -/// target, journal, and stage paths must stay regular non-link files; journal reads use the bounded -/// no-follow project reader; mismatched identities fail closed without deleting either file. +/// Security Notes: journal names are derived from the selected target and stage names are generated +/// UUID-based same-directory names; target, journal, and stage paths must stay regular non-link files; +/// journal reads use the bounded no-follow project reader; mismatched identities fail closed without +/// deleting either file. #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { let parent = project_parent(target); @@ -678,34 +793,57 @@ pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { return Err(PROJECT_RECOVERY_ERROR.to_string()); } let target_name = journal_path_name(target)?; - for entry in fs::read_dir(parent).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())? { - let entry = entry.map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - let journal_path = entry.path(); - if !recovery_journal_name(&journal_path) { - continue; - } - let metadata = fs::symlink_metadata(&journal_path) - .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - if !metadata_is_regular_project_file(&metadata) { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } - let content = read_project_file(&journal_path) - .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - if content.len() > MAX_RECOVERY_JOURNAL_BYTES { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } - let journal: PublicationJournal = - serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; - if journal.target_name != target_name { - continue; - } - if journal.version != 1 || !generated_stage_name(&journal.stage_name) { - return Err(PROJECT_RECOVERY_ERROR.to_string()); - } - let stage = path_from_journal_name(parent, &journal.stage_name) - .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; - recover_publication_state(target, &journal_path, &journal, &stage)?; + let prepared_path = publication_journal_path(target, false)?; + let published_path = publication_journal_path(target, true)?; + let prepared_exists = recovery_artifact_exists(&prepared_path)?; + let published_exists = recovery_artifact_exists(&published_path)?; + if prepared_exists && published_exists { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let Some((journal_path, published)) = (if prepared_exists { + Some((prepared_path, false)) + } else if published_exists { + Some((published_path, true)) + } else { + None + }) else { + return Ok(()); + }; + let metadata = fs::symlink_metadata(&journal_path) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if !metadata_is_regular_project_file(&metadata) { + return Err(PROJECT_RECOVERY_ERROR.to_string()); } + let content = read_project_file_with_opener( + &journal_path, + open_project_file, + MAX_RECOVERY_JOURNAL_BYTES, + PROJECT_RECOVERY_ERROR, + ) + .map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + let journal: PublicationJournal = + serde_json::from_str(&content).map_err(|_| PROJECT_RECOVERY_ERROR.to_string())?; + if journal.target_name != target_name { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + if journal.version != 1 + || !generated_stage_name(&journal.candidate_name) + || !generated_stage_name(&journal.displaced_name) + { + return Err(PROJECT_RECOVERY_ERROR.to_string()); + } + let candidate_stage = path_from_journal_name(parent, &journal.candidate_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + let displaced = path_from_journal_name(parent, &journal.displaced_name) + .ok_or_else(|| PROJECT_RECOVERY_ERROR.to_string())?; + recover_publication_state( + target, + &journal_path, + &journal, + &candidate_stage, + &displaced, + published, + )?; Ok(()) } @@ -721,7 +859,7 @@ pub(crate) fn replace_existing_project_file( expected: &ProjectFileIdentity, ) -> Result<(), String> { let candidate = project_file_identity(stage)?; - let journal = create_publication_journal(target, stage, expected, &candidate)?; + let journal = create_publication_journal(target, stage, stage, expected, &candidate)?; if rename_exchange(stage, target).is_err() { remove_stage(stage); remove_stage(&journal); @@ -730,16 +868,13 @@ pub(crate) fn replace_existing_project_file( let displaced = project_file_identity(stage); if displaced.as_ref().is_ok_and(|identity| identity == expected) { - remove_stage(stage); - remove_stage(&journal); - return Ok(()); + return finish_successful_publication(&journal, stage, target); } let target_is_candidate = project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && rename_exchange(stage, target).is_ok() { - remove_stage(stage); - remove_stage(&journal); + finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -752,7 +887,7 @@ pub(crate) fn replace_existing_project_file( ) -> Result<(), String> { let candidate = project_file_identity(stage)?; let backup = staging_path(target)?; - let journal = create_publication_journal(target, &backup, expected, &candidate)?; + let journal = create_publication_journal(target, stage, &backup, expected, &candidate)?; if replace_file_with_backup(target, stage, &backup).is_err() { remove_stage(stage); remove_stage(&journal); @@ -761,16 +896,13 @@ pub(crate) fn replace_existing_project_file( let displaced = project_file_identity(&backup); if displaced.as_ref().is_ok_and(|identity| identity == expected) { - remove_stage(&backup); - remove_stage(&journal); - return Ok(()); + return finish_successful_publication(&journal, &backup, target); } let target_is_candidate = project_file_identity(target).is_ok_and(|identity| identity == candidate); if target_is_candidate && replace_file_with_backup(target, &backup, stage).is_ok() { - remove_stage(stage); - remove_stage(&journal); + finish_rolled_back_publication(stage, &journal, target); } Err(PROJECT_PUBLISH_ERROR.to_string()) } @@ -828,7 +960,12 @@ fn project_parent_chain_is_safe(parent: &Path) -> bool { }) } -fn read_project_file_with_opener(target: &Path, open_file: F) -> Result +fn read_project_file_with_opener( + target: &Path, + open_file: F, + max_bytes: usize, + too_large_error: &str, +) -> Result where F: FnOnce(&Path) -> std::io::Result, { @@ -887,13 +1024,13 @@ where #[cfg(not(any(unix, windows)))] return Err(PROJECT_READ_ERROR.to_string()); - let mut reader = file.take((MAX_PROJECT_FILE_BYTES + 1) as u64); + let mut reader = file.take((max_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()); + if bytes.len() > max_bytes { + return Err(too_large_error.to_string()); } String::from_utf8(bytes).map_err(|_| PROJECT_READ_ERROR.to_string()) } @@ -909,7 +1046,12 @@ where /// fail closed until their no-follow open contract is explicitly modeled. The reader remains capped /// at `MAX_PROJECT_FILE_BYTES + 1`; backup rotation and migration semantics remain later #962 work. pub(crate) fn read_project_file(target: &Path) -> Result { - read_project_file_with_opener(target, open_project_file) + read_project_file_with_opener( + target, + open_project_file, + MAX_PROJECT_FILE_BYTES, + PROJECT_TOO_LARGE_ERROR, + ) } /// Publishes a selected project only after its complete bounded bytes are staged and synced. @@ -1029,7 +1171,7 @@ where mod tests { use super::{ publish_new_project_file, read_project_file, read_project_file_with_opener, - MAX_PROJECT_FILE_BYTES, + MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR, }; use std::{ fs, @@ -1243,7 +1385,7 @@ mod tests { fs::rename(path, &parked)?; fs::rename(&replacement, path)?; fs::File::open(path) - }) + }, MAX_PROJECT_FILE_BYTES, PROJECT_TOO_LARGE_ERROR) .expect_err("a path replacement between preflight and open must fail closed"); assert_eq!(error, "Failed to read file"); @@ -1283,6 +1425,7 @@ mod tests { let journal = super::create_publication_journal( &target, &stage, + &stage, &expected, &candidate_identity, ) @@ -1298,6 +1441,66 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn cleans_a_durable_published_journal_after_target_exchange() { + let root = test_dir("published-recovery"); + let target = root.join("setlist.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let known_good = br#"{"id":"known-good"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, known_good).expect("known-good fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let prepared = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + super::rename_exchange(&stage, &target).expect("fixture should model target exchange"); + let published = super::publication_journal_path(&target, true) + .expect("published journal path should be derivable"); + super::rename_noreplace(&prepared, &published) + .expect("fixture should model the durable published marker"); + + super::recover_project_publication(&target) + .expect("the next selection should clean the completed publication"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), candidate); + assert!(!stage.exists(), "the displaced known-good stage should be cleaned"); + assert!(!published.exists(), "the published journal should be cleaned"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn unrelated_incomplete_journals_do_not_block_project_recovery() { + let root = test_dir("unrelated-recovery"); + let target = root.join("selected.bscope"); + let unrelated = root.join("other.bscope"); + fs::write(&target, br#"{"id":"selected"}"#).expect("target fixture should be written"); + fs::write( + super::publication_journal_path(&unrelated, false) + .expect("unrelated journal path should be derivable"), + b"{", + ) + .expect("the incomplete unrelated journal should be written"); + + super::recover_project_publication(&target) + .expect("an unrelated incomplete journal must not block recovery"); + assert_eq!( + fs::read(&target).expect("target should remain readable"), + br#"{"id":"selected"}"# + ); + 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"); From 3006cd6ec452e6e99e498fe098174f5a4e044a76 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 01:19:44 +0900 Subject: [PATCH 4/4] fix(project): recover raced publication rollback --- .../src-tauri/src/project_persistence.rs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/project_persistence.rs b/apps/desktop/src-tauri/src/project_persistence.rs index 7f65e0340..0e7ab0bc2 100644 --- a/apps/desktop/src-tauri/src/project_persistence.rs +++ b/apps/desktop/src-tauri/src/project_persistence.rs @@ -725,7 +725,9 @@ fn recover_publication_state( } if target_identity.as_ref() == Some(&journal.candidate) - && displaced_identity.as_ref() == Some(&journal.expected) + && displaced_identity + .as_ref() + .is_some_and(|identity| identity != &journal.candidate) { #[cfg(any(target_os = "linux", target_os = "macos"))] if rename_exchange(displaced, target).is_err() { @@ -784,8 +786,7 @@ fn recover_publication_state( /// /// Security Notes: journal names are derived from the selected target and stage names are generated /// UUID-based same-directory names; target, journal, and stage paths must stay regular non-link files; -/// journal reads use the bounded no-follow project reader; mismatched identities fail closed without -/// deleting either file. +/// journal reads use the bounded no-follow project reader; unrecognized identity pairs fail closed. #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub(crate) fn recover_project_publication(target: &Path) -> Result<(), String> { let parent = project_parent(target); @@ -1441,6 +1442,47 @@ mod tests { fs::remove_dir_all(root).expect("test directory should be removable"); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn recovers_a_competing_file_preserved_by_an_interrupted_exchange() { + let root = test_dir("raced-recovery"); + let target = root.join("setlist.bscope"); + let parked = root.join("parked-authorized.bscope"); + let stage = root.join(format!(".bandscope-stage-{}.stage", uuid::Uuid::new_v4())); + let authorized = br#"{"id":"authorized"}"#; + let racer = br#"{"id":"racer"}"#; + let candidate = br#"{"id":"candidate"}"#; + fs::write(&target, authorized).expect("authorized fixture should be written"); + fs::write(&stage, candidate).expect("candidate fixture should be written"); + + let expected = super::project_file_identity(&target).expect("target identity should exist"); + let candidate_identity = + super::project_file_identity(&stage).expect("candidate identity should exist"); + let journal = super::create_publication_journal( + &target, + &stage, + &stage, + &expected, + &candidate_identity, + ) + .expect("the recovery journal should be durable before publication"); + fs::rename(&target, &parked).expect("authorized target should be parked by the racer"); + fs::write(&target, racer).expect("racer should win the target pathname"); + super::rename_exchange(&stage, &target).expect("fixture should model interrupted exchange"); + + super::recover_project_publication(&target) + .expect("the preserved competing file should be restored"); + + assert_eq!(fs::read(&target).expect("target should remain readable"), racer); + assert_eq!( + fs::read(&parked).expect("the authorized file should remain readable"), + authorized + ); + assert!(!stage.exists(), "the candidate should be cleaned"); + assert!(!journal.exists(), "the recovery journal should be cleaned"); + fs::remove_dir_all(root).expect("test directory should be removable"); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn cleans_a_durable_published_journal_after_target_exchange() {