From 1784f231860ec024f07f38cf5e2311d613e738f9 Mon Sep 17 00:00:00 2001 From: Sean Shaffer Date: Tue, 3 Nov 2026 22:46:10 -0600 Subject: [PATCH] fix: publish complete storage migrations through owned staging handles --- docs/CHANGELOG.md | 2 + docs/MIGRATION.md | 39 ++- src/migration_native.rs | 280 ++++++++++++++++ src/paths.rs | 691 ++++++++++++++++++++++++++++++++++++---- src/win32.rs | 4 +- 5 files changed, 951 insertions(+), 65 deletions(-) create mode 100644 src/migration_native.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f36666..a448876 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Fixed +- Legacy storage migration stages and flushes the complete data set before + publication, preserving legacy storage on failure and existing foreign entries. - **Projection mode keeps the History rail.** Turning on classroom projection enlarges the whole window (1.5x), which used to make the History rail drop away and the top row run off the right edge diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 8c038cf..36073ec 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -22,8 +22,12 @@ How the move works: 1. On first run, if the `data` folder already holds any of these files, it is used as-is. -2. Otherwise, any of the files found in `%APPDATA%\CalcDex` - are **copied** into `data\`. The originals are left +2. Otherwise, existing known files in `%APPDATA%\CalcDex` + are **copied** into an exclusively created sibling staging directory. + Each copy and a completion marker are flushed with `sync_all` before + the whole directory is published as `data\`. Windows publication + refuses any existing destination, including one created concurrently. + Missing optional files are valid. The originals are left untouched — the copy is one-way, and going back to an older version simply uses the old files again (it will not see changes made since, so treat the move as @@ -33,6 +37,37 @@ How the move works: `%APPDATA%\CalcDex` location is used for that run — an existing user's files are never lost to a failed move. +An existing empty or foreign-only `data\` is preserved; migration falls +back to legacy storage with a warning rather than merging or deleting entries. +Reparse points in migration paths and nonregular legacy entries are refused. +Source files are held against writes/deletion while copying on Windows. +Failed copies, flushes, completion recording or publication leave legacy +storage selected. A new process retries from the current legacy files; +sibling staging remnants are never selected. Staging is created atomically with its owned Windows handle. Outputs are +created relative to that handle and held against replacement until flushed. +Publication uses the same staging handle with a fully qualified target under +the pinned parent, without replacing an existing destination. Child handles +close before publication; a subsequent failure retains staging and warns. +Earlier failures delete only still-owned child handles and the empty staging +handle. Foreign entries are retained with a warning; cleanup never recursively +deletes or reopens paths to infer ownership. The retained staging path is +reported through the warning. This assumes trusted stable ancestors; handle +pinning does not prove containment against malicious same-token attribute +rewriting. + +`paths::data_resolution()` exposes the cached directory and optional warning; +`paths::resolve_locations()` permits independent resolution with explicit paths. +The current GUI does not display this additive warning seam. These operations +do not change calculator behavior or file schemas. File flushes and atomic +publication do not constitute a tested guarantee against sudden power loss. + +**Manual recovery for older partial migrations:** an unmarked `data\` holding +any known file remains authoritative for compatibility with portable installs. +It cannot reliably be distinguished from a partial copy left by an older +version. CalcDex never automatically merges or deletes it. Close CalcDex, +back up both directories, compare their known files, and select the desired +complete data set manually (or use the override). Preserve foreign files. + The `CALCDEX_DATA_DIR` environment variable, when set, points the data folder at an exact location instead (used by scripts and the test suite). diff --git a/src/migration_native.rs b/src/migration_native.rs new file mode 100644 index 0000000..5e75c25 --- /dev/null +++ b/src/migration_native.rs @@ -0,0 +1,280 @@ +//! Private Windows migration operations; names are single internal components. +//! ABI: Windows SDK 10.0.26100.0 um/winternl.h and um/winbase.h. +use std::ffi::c_void; +use std::fs::File; +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle}; +use std::ptr::null_mut; + +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +#[repr(C)] +struct ObjectAttributes { + length: u32, + root: *mut c_void, + name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, +} + +#[repr(C)] +struct IoStatusBlock { + status_or_pointer: usize, + information: usize, +} + +#[repr(C)] +struct RenameInfo { + flags: u32, + root: *mut c_void, + length: u32, + name: [u16; 1], +} + +#[link(name = "ntdll")] +extern "system" { + fn NtCreateFile( + handle: *mut *mut c_void, + access: u32, + attributes: *mut ObjectAttributes, + status: *mut IoStatusBlock, + allocation_size: *mut i64, + file_attributes: u32, + share: u32, + disposition: u32, + options: u32, + ea_buffer: *mut c_void, + ea_length: u32, + ) -> i32; + fn RtlNtStatusToDosError(status: i32) -> u32; +} + +#[link(name = "kernel32")] +extern "system" { + fn SetFileInformationByHandle( + handle: *mut c_void, + class: i32, + information: *const c_void, + size: u32, + ) -> i32; +} + +pub(super) fn create(parent: &File, name: &str, directory: bool) -> io::Result { + // Only generated staging names and fixed output names cross this boundary. + if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', ':', '\0']) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid migration component", + )); + } + let mut wide: Vec = name.encode_utf16().collect(); + let length = u16::try_from(wide.len() * 2) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "migration name too long"))?; + let mut unicode = UnicodeString { + length, + maximum_length: length, + buffer: wide.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: size_of::() as u32, + root: parent.as_raw_handle(), + name: &mut unicode, + attributes: 0x1040, // OBJ_CASE_INSENSITIVE | OBJ_DONT_REPARSE + security_descriptor: null_mut(), + security_quality_of_service: null_mut(), + }; + let mut status = IoStatusBlock { + status_or_pointer: 0, + information: 0, + }; + let mut handle = null_mut(); + // DELETE | SYNCHRONIZE | READ_ATTRIBUTES; directory LIST/ADD_FILE or file WRITE_DATA. + let access = 0x00110080 | if directory { 3 } else { 2 }; + // All buffers live through this synchronous call. FILE_CREATE never acquires an existing object. + let result = unsafe { + NtCreateFile( + &mut handle, + access, + &mut attributes, + &mut status, + null_mut(), + 0x80, + if directory { 3 } else { 1 }, + 2, + 0x20 | if directory { 1 } else { 0x40 }, + null_mut(), + 0, + ) + }; + if result < 0 { + // Conversion has no ownership side effects; a failed create returns no owned handle. + return Err(io::Error::from_raw_os_error( + unsafe { RtlNtStatusToDosError(result) } as i32, + )); + } + // The successful native call transfers this handle exactly once. + let file = unsafe { File::from_raw_handle(handle) }; + if status.information != 2 { + return Err(io::Error::other( + "native migration create did not report FILE_CREATED", + )); + } + Ok(file) +} + +pub(super) fn delete(file: &File) -> io::Result<()> { + // FILE_DISPOSITION_INFO::DeleteFile (BOOLEAN). + // The one-byte SDK structure and retained DELETE-capable handle are valid for the call. + let delete: u8 = 1; + if unsafe { + SetFileInformationByHandle(file.as_raw_handle(), 4, (&delete as *const u8).cast(), 1) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +pub(super) fn publish(stage: &File, target: &std::path::Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + if !target.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "migration target must be absolute", + )); + } + let wide: Vec = target.as_os_str().encode_wide().collect(); + let bytes = wide + .len() + .checked_mul(2) + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "migration target too long"))?; + let offset = std::mem::offset_of!(RenameInfo, name); + let size = std::mem::size_of::().max(offset + bytes as usize + 2); + let mut buffer = vec![0u64; size.div_ceil(8)]; + let length = u32::try_from(buffer.len() * 8) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "migration rename too large"))?; + let ptr = buffer.as_mut_ptr().cast::(); + // Zeroed, 8-aligned SDK buffer: ReplaceIfExists=false, RootDirectory=NULL. + // Parent/ancestor pins remain live; the source is the original creation handle. + unsafe { + ptr.add(std::mem::offset_of!(RenameInfo, length)) + .cast::() + .write(bytes); + std::ptr::copy_nonoverlapping(wide.as_ptr().cast::(), ptr.add(offset), bytes as usize); + if SetFileInformationByHandle(stage.as_raw_handle(), 3, ptr.cast(), length) == 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdk_x64_layouts() { + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::offset_of!(UnicodeString, buffer), 8); + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(std::mem::offset_of!(ObjectAttributes, root), 8); + assert_eq!(std::mem::offset_of!(ObjectAttributes, name), 16); + assert_eq!(std::mem::offset_of!(ObjectAttributes, attributes), 24); + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::offset_of!(IoStatusBlock, information), 8); + assert_eq!(std::mem::size_of::(), 24); + assert_eq!(std::mem::align_of::(), 8); + assert_eq!(std::mem::offset_of!(RenameInfo, root), 8); + assert_eq!(std::mem::offset_of!(RenameInfo, length), 16); + assert_eq!(std::mem::offset_of!(RenameInfo, name), 20); + } + + #[test] + fn owned_fixture_absolute_publication_collision_and_cleanup() { + use std::io::Write; + use std::os::windows::fs::OpenOptionsExt; + for collision in [false, true] { + let root = std::env::temp_dir().join(format!( + "calcdex-stage-final-b-native-{}-{collision}", + std::process::id() + )); + std::fs::create_dir(&root).unwrap(); + let parent = std::fs::OpenOptions::new() + .access_mode(0) + .share_mode(3) + .custom_flags(0x02200000) + .open(&root) + .unwrap(); + let stage = create(&parent, "stage", true).unwrap(); + assert_eq!( + create(&parent, "stage", true).unwrap_err().kind(), + io::ErrorKind::AlreadyExists + ); + assert!(std::fs::rename(root.join("stage"), root.join("displaced")).is_err()); + let mut child = create(&stage, "child", false).unwrap(); + child.write_all(b"owned bytes").unwrap(); + child.sync_all().unwrap(); + assert!(std::fs::remove_file(root.join("stage/child")).is_err()); + assert!( + std::fs::rename(root.join("stage/child"), root.join("stage/replaced")).is_err() + ); + assert!(std::fs::write(root.join("stage/child"), b"replacement").is_err()); + std::fs::write(root.join("stage/foreign"), b"foreign sentinel").unwrap(); + delete(&child).unwrap(); + drop(child); + assert!(!root.join("stage/child").exists()); + assert!(delete(&stage).is_err()); + assert_eq!( + std::fs::read(root.join("stage/foreign")).unwrap(), + b"foreign sentinel" + ); + let mut child = create(&stage, "published", false).unwrap(); + child.write_all(b"published bytes").unwrap(); + child.sync_all().unwrap(); + drop(child); + if collision { + std::fs::create_dir(root.join("data")).unwrap(); + std::fs::write(root.join("data/sentinel"), b"destination sentinel").unwrap(); + } + assert!(std::fs::rename(root.join("stage"), root.join("displaced")).is_err()); + let result = publish(&stage, &root.join("data")); + if collision { + assert!(result.is_err(), "{result:?}"); + assert_eq!( + std::fs::read(root.join("data/sentinel")).unwrap(), + b"destination sentinel" + ); + assert_eq!( + std::fs::read(root.join("stage/published")).unwrap(), + b"published bytes" + ); + assert!(delete(&stage).is_err()); + } else { + assert!( + result.is_ok(), + "absolute retained-guard publication: {result:?}" + ); + assert_eq!( + std::fs::read(root.join("data/published")).unwrap(), + b"published bytes" + ); + assert_eq!( + std::fs::read(root.join("data/foreign")).unwrap(), + b"foreign sentinel" + ); + } + let empty = create(&parent, "empty", true).unwrap(); + delete(&empty).unwrap(); + drop(empty); + assert!(!root.join("empty").exists()); + // Fixture retained for inspection; no path-based ownership cleanup. + } + } +} diff --git a/src/paths.rs b/src/paths.rs index 307741d..36651c3 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -45,75 +45,295 @@ pub fn legacy_dir() -> Option { } } -fn dir_holds_known_files(dir: &Path) -> bool { - KNOWN_FILES.iter().any(|f| dir.join(f).exists()) -} - -/// Resolve the data folder (see the module docs for the order). -/// Performs the one-way legacy migration copy on the way through -/// when that is what puts `data/` into use. -fn resolve_data_dir() -> PathBuf { - // Step 0: the explicit override wins outright. - if let Ok(dir) = std::env::var("CALCDEX_DATA_DIR") { - if !dir.is_empty() { - return PathBuf::from(dir); - } - } - let exe_dir = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.to_path_buf())); - let legacy = legacy_dir(); - if let Some(exe_dir) = &exe_dir { - let data = exe_dir.join("data"); - if dir_holds_known_files(&data) { - return data; - } - if let Some(legacy) = &legacy { - if dir_holds_known_files(legacy) { - // One-way copy migration: new folder absent, old - // folder in use. Copy what exists; the originals - // stay untouched as the backup of the move. - if std::fs::create_dir_all(&data).is_ok() { - let mut copied = false; - let mut failed = false; - for name in KNOWN_FILES { - let from = legacy.join(name); - let to = data.join(name); - if from.exists() && !to.exists() { - match std::fs::copy(&from, &to) { - Ok(_) => copied = true, - Err(_) => failed = true, - } - } - } - if copied && !failed { - return data; - } +/// Cached storage selection and a migration warning, if any. +#[derive(Clone, Debug)] +pub struct DataResolution { + pub directory: PathBuf, + pub warning: Option, +} + +fn known_entries(dir: &Path) -> bool { + KNOWN_FILES + .iter() + .any(|name| std::fs::symlink_metadata(dir.join(name)).is_ok()) +} + +fn selected(directory: PathBuf) -> DataResolution { + DataResolution { + directory, + warning: None, + } +} + +/// Resolve explicit locations independently of the process cache. +/// Overrides bypass all migration and filesystem inspection. +pub fn resolve_locations( + override_dir: Option, + exe: Option, + legacy: Option, +) -> DataResolution { + resolve_with(override_dir, exe, legacy, &mut |_| Ok(())) +} + +fn resolve_with( + override_dir: Option, + exe: Option, + legacy: Option, + boundary: &mut impl FnMut(&str) -> std::io::Result<()>, +) -> DataResolution { + if let Some(dir) = override_dir { + return selected(dir); + } + if let Some(exe) = &exe { + let data = exe.join("data"); + let portable = pin_directories(&data).is_ok() && known_entries(&data); + if portable { + return selected(data); + } + if let Some(old) = legacy.as_ref().filter(|old| known_entries(old)) { + return match migrate(exe, old, &data, boundary) { + Ok(()) => selected(data), + Err(error) => DataResolution { + directory: old.clone(), + warning: Some(format!( + "Storage migration failed; using legacy storage {}: {error}", + old.display() + )), + }, + }; + } + if std::fs::create_dir(&data).is_ok() || pin_directories(&data).is_ok() { + return selected(data); + } + } + selected(legacy.or(exe).unwrap_or_else(|| PathBuf::from("."))) +} + +// Keep every existing ancestor pinned against replacement on Windows. Opening +// the reparse point itself lets us reject it before traversing through it. +fn pin_directories(path: &Path) -> std::io::Result> { + let mut pins = Vec::new(); + let mut ancestors: Vec<_> = path + .ancestors() + .filter(|p| !p.as_os_str().is_empty()) + .collect(); + ancestors.reverse(); + for ancestor in ancestors { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options + .access_mode(0) + .custom_flags(0x02200000) + .share_mode(3); // BACKUP_SEMANTICS | OPEN_REPARSE_POINT, no DELETE sharing + } + let file = options.open(ancestor).map_err(|error| { + std::io::Error::new( + error.kind(), + format!("cannot pin {}: {error}", ancestor.display()), + ) + })?; + let metadata = file.metadata()?; + if !metadata.is_dir() || is_reparse(&metadata) { + return Err(std::io::Error::other( + "storage path contains a reparse point or non-directory", + )); + } + pins.push(file); + } + Ok(pins) +} + +fn is_reparse(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +#[cfg(windows)] +#[path = "migration_native.rs"] +mod native; + +#[cfg(not(windows))] +fn migrate( + _exe: &Path, + _legacy: &Path, + _data: &Path, + _boundary: &mut impl FnMut(&str) -> std::io::Result<()>, +) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "migration requires Windows", + )) +} + +#[cfg(windows)] +fn migrate( + exe: &Path, + legacy: &Path, + data: &Path, + boundary: &mut impl FnMut(&str) -> std::io::Result<()>, +) -> std::io::Result<()> { + // Fully qualified target, pinned parent and trusted stable ancestors remain + // required; pins do not defeat malicious same-token attribute rewriting. + let exe = std::path::absolute(exe)?; + let data = std::path::absolute(data)?; + if data.parent() != Some(exe.as_path()) + || data.file_name() != Some(std::ffi::OsStr::new("data")) + { + return Err(std::io::Error::other( + "migration target is not the pinned parent's data directory", + )); + } + let parents = pin_directories(&exe)?; + let parent = parents + .last() + .ok_or_else(|| std::io::Error::other("missing migration parent"))?; + let _source_dirs = pin_directories(legacy)?; + if std::fs::symlink_metadata(&data).is_ok() { + return Err(std::io::Error::other( + "data destination already exists; preserved without merging", + )); + } + // Hold all source handles with no write/delete sharing throughout copying. + let mut sources = Vec::new(); + for name in KNOWN_FILES { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000).share_mode(1); + } + match options.open(legacy.join(name)) { + Ok(file) => { + let metadata = file.metadata()?; + if !metadata.is_file() || is_reparse(&metadata) { + return Err(std::io::Error::other("legacy entry is not a regular file")); } - // data/ unusable: stay on the legacy folder rather - // than strand the user's files. - return legacy.clone(); + sources.push((name, file)); } - } - // Fresh install: prefer data/ beside the executable. - if std::fs::create_dir_all(&data).is_ok() { - return data; + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error), } } - if let Some(legacy) = legacy { - return legacy; + if sources.is_empty() { + return Err(std::io::Error::other("legacy files disappeared")); + } + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let mut owned = None; + for _ in 0..32 { + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let candidate = exe.join(format!(".calcdex-migration-{}-{id}", std::process::id())); + let name = candidate + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| std::io::Error::other("invalid generated staging name"))?; + match native::create(parent, name, true) { + Ok(handle) => { + owned = Some((candidate, handle)); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error), + } } - if let Some(exe_dir) = exe_dir { - return exe_dir; + let (stage, guard) = owned + .ok_or_else(|| std::io::Error::other("cannot allocate migration staging directory"))?; + let mut created = Vec::new(); + let mut children_closed = false; + let result = (|| { + for (name, source) in &mut sources { + boundary(&format!("copy:{name}"))?; + created.push(native::create(&guard, name, false)?); + let index = created.len() - 1; + let output = &mut created[index]; + std::io::copy(source, output)?; + boundary(&format!("flush:{name}"))?; + output.sync_all()?; + } + for name in KNOWN_FILES { + if std::fs::symlink_metadata(legacy.join(name)).is_ok() + != sources.iter().any(|(existing, _)| *existing == name) + { + return Err(std::io::Error::other( + "legacy file set changed during migration", + )); + } + } + boundary("completion:create")?; + created.push(native::create(&guard, ".migration-complete", false)?); + let index = created.len() - 1; + let complete = &mut created[index]; + complete.write_all(b"CalcDex migration complete v1\n")?; + boundary("completion:flush")?; + complete.sync_all()?; + // Windows directory publication requires closed children. Their identity + // must never be reacquired by path if publication subsequently fails. + children_closed = true; + created.clear(); + boundary("publish")?; + native::publish(&guard, &data) + })(); + if let Err(error) = result { + let cleanup = (|| { + boundary("cleanup")?; + if children_closed { + return Err(std::io::Error::other( + "child ownership handles closed before publication", + )); + } + let mut failure = None; + for file in created.drain(..) { + if let Err(error) = native::delete(&file) { + failure = Some(error); + } + } + if let Some(error) = failure { + return Err(error); + } + native::delete(&guard) + })(); + return match cleanup { + Ok(()) => Err(error), + Err(cleanup) => Err(std::io::Error::other(format!( + "{error}; owned staging retained at {}: {cleanup}", + stage.display() + ))), + }; } - PathBuf::from(".") + Ok(()) +} + +/// The process-wide selection, including a warning available to host adapters. +pub fn data_resolution() -> DataResolution { + static RESOLUTION: OnceLock = OnceLock::new(); + RESOLUTION + .get_or_init(|| { + resolve_locations( + std::env::var_os("CALCDEX_DATA_DIR") + .filter(|p| !p.is_empty()) + .map(PathBuf::from), + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)), + legacy_dir(), + ) + }) + .clone() } -/// The folder holding the app's files (see module docs). Resolved -/// once per process, migration included. +/// The folder holding the app's files, resolved once per process. pub fn data_dir() -> PathBuf { - static DIR: OnceLock = OnceLock::new(); - DIR.get_or_init(resolve_data_dir).clone() + data_resolution().directory } /// One of the app's files inside the data folder. @@ -162,3 +382,354 @@ pub fn purge_candidates(dir: &Path) -> Vec { .filter(|p| p.exists()) .collect() } + +#[cfg(all(test, windows))] +mod migration_tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Fixture { + root: PathBuf, + exe: PathBuf, + old: PathBuf, + } + impl Fixture { + fn new() -> Self { + static ID: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "calcdex-migration-b-{}-{}", + std::process::id(), + ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + let exe = root.join("app"); + let old = root.join("legacy"); + fs::create_dir(&exe).unwrap(); + fs::create_dir(&old).unwrap(); + for (i, name) in KNOWN_FILES.iter().enumerate() { + fs::write(old.join(name), [i as u8, 0, 255]).unwrap(); + } + Self { root, exe, old } + } + fn resolve(&self, hook: &mut impl FnMut(&str) -> std::io::Result<()>) -> DataResolution { + resolve_with(None, Some(self.exe.clone()), Some(self.old.clone()), hook) + } + fn restart(&self) -> DataResolution { + resolve_locations(None, Some(self.exe.clone()), Some(self.old.clone())) + } + fn originals(&self) { + for (i, name) in KNOWN_FILES.iter().enumerate() { + assert_eq!(fs::read(self.old.join(name)).unwrap(), [i as u8, 0, 255]); + } + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } + } + fn failure() -> std::io::Error { + std::io::Error::other("injected filesystem failure") + } + + #[test] + fn stage_cannot_be_renamed_or_substituted_before_copy_publish_or_cleanup() { + for point in ["copy:settings.txt", "publish", "cleanup"] { + let f = Fixture::new(); + let mut reached = false; + let result = f.resolve(&mut |step| { + if step == point { + reached = true; + let stage = fs::read_dir(&f.exe)?.next().unwrap()?.path(); + assert!(fs::rename(&stage, f.exe.join("displaced")).is_err()); + assert!(fs::create_dir(&stage).is_err()); + fs::write(stage.join("foreign"), b"foreign sentinel")?; + return Err(failure()); + } + if point == "cleanup" && step == "copy:settings.txt" { + return Err(failure()); + } + Ok(()) + }); + assert!(reached, "{point}: {:?}", result.warning); + assert_eq!(result.directory, f.old); + assert!(result + .warning + .unwrap() + .contains("owned staging retained at")); + let stage = fs::read_dir(&f.exe) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!( + fs::read(stage.join("foreign")).unwrap(), + b"foreign sentinel" + ); + f.originals(); + } + } + + #[test] + fn owned_children_reject_replacement_and_cleanup_preserves_foreign_entries() { + for point in ["flush:settings.txt", "completion:flush"] { + let f = Fixture::new(); + let mut reached = false; + let result = f.resolve(&mut |step| { + if step == point { + reached = true; + let stage = fs::read_dir(&f.exe)?.next().unwrap()?.path(); + let name = if point == "completion:flush" { + ".migration-complete" + } else { + "settings.txt" + }; + assert!(fs::remove_file(stage.join(name)).is_err()); + assert!(fs::rename(stage.join(name), stage.join("replaced")).is_err()); + assert!(fs::write(stage.join(name), b"replacement").is_err()); + fs::write(stage.join("foreign"), b"foreign sentinel")?; + return Err(failure()); + } + Ok(()) + }); + assert!(reached, "{:?}", result.warning); + assert_eq!(result.directory, f.old); + assert!(result + .warning + .unwrap() + .contains("owned staging retained at")); + let stage = fs::read_dir(&f.exe) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!( + fs::read(stage.join("foreign")).unwrap(), + b"foreign sentinel" + ); + assert_eq!(fs::read_dir(stage).unwrap().count(), 1); + f.originals(); + } + } + + #[test] + fn every_copy_flush_completion_and_publication_failure_restarts_from_latest_legacy() { + let mut failures: Vec = KNOWN_FILES + .iter() + .flat_map(|name| [format!("copy:{name}"), format!("flush:{name}")]) + .collect(); + failures.extend(["completion:create", "completion:flush", "publish"].map(String::from)); + for point in failures { + let f = Fixture::new(); + let mut seen = false; + let result = f.resolve(&mut |step| { + if step == point { + seen = true; + Err(failure()) + } else { + Ok(()) + } + }); + assert!(seen, "{point}"); + assert_eq!(result.directory, f.old, "{point}"); + assert!(result.warning.is_some()); + assert!(!f.exe.join("data").exists()); + assert_eq!( + fs::read_dir(&f.exe).unwrap().count(), + usize::from(point == "publish") + ); + if point == "publish" { + assert!(result + .warning + .as_ref() + .unwrap() + .contains("owned staging retained at")); + let stage = fs::read_dir(&f.exe) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert!(stage.join(".migration-complete").is_file()); + for name in KNOWN_FILES { + assert_eq!( + fs::read(stage.join(name)).unwrap(), + fs::read(f.old.join(name)).unwrap() + ); + } + } + f.originals(); + fs::write(f.old.join("history.txt"), b"latest legacy edit").unwrap(); + let restarted = f.restart(); + assert_eq!(restarted.directory, f.exe.join("data")); + assert!(restarted.warning.is_none()); + assert_eq!( + fs::read(restarted.directory.join("history.txt")).unwrap(), + b"latest legacy edit" + ); + assert!(restarted.directory.join(".migration-complete").is_file()); + assert_eq!(f.restart().directory, restarted.directory); + } + } + + #[test] + fn successful_transaction_preserves_all_bytes_and_optional_absence() { + let f = Fixture::new(); + f.originals(); + fs::remove_file(f.old.join("examples.txt")).unwrap(); + let result = f.restart(); + assert!(result.warning.is_none(), "{:?}", result.warning); + for name in KNOWN_FILES + .into_iter() + .filter(|name| *name != "examples.txt") + { + assert_eq!( + fs::read(f.old.join(name)).unwrap(), + fs::read(result.directory.join(name)).unwrap() + ); + } + assert!(!result.directory.join("examples.txt").exists()); + } + + #[test] + fn overrides_and_existing_portable_are_authoritative_without_copying() { + let f = Fixture::new(); + let override_dir = f.root.join("override"); + let result = resolve_locations( + Some(override_dir.clone()), + Some(f.exe.clone()), + Some(f.old.clone()), + ); + assert_eq!(result.directory, override_dir); + assert!(!override_dir.exists()); + fs::create_dir(f.exe.join("data")).unwrap(); + fs::write(f.exe.join("data/history.txt"), b"portable authority").unwrap(); + assert_eq!(f.restart().directory, f.exe.join("data")); + assert_eq!( + fs::read(f.exe.join("data/history.txt")).unwrap(), + b"portable authority" + ); + assert!(!f.exe.join("data/settings.txt").exists()); + f.originals(); + } + + #[test] + fn existing_empty_or_foreign_destination_is_preserved() { + for foreign in [false, true] { + let f = Fixture::new(); + fs::create_dir(f.exe.join("data")).unwrap(); + if foreign { + fs::write(f.exe.join("data/sentinel"), b"foreign").unwrap(); + } + let result = f.restart(); + assert_eq!(result.directory, f.old); + assert!(result.warning.unwrap().contains("already exists")); + if foreign { + assert_eq!(fs::read(f.exe.join("data/sentinel")).unwrap(), b"foreign"); + } + f.originals(); + } + } + + #[test] + fn windows_publication_never_replaces_a_racing_empty_or_foreign_directory() { + for foreign in [false, true] { + let f = Fixture::new(); + let result = f.resolve(&mut |step| { + if step == "publish" { + fs::create_dir(f.exe.join("data"))?; + if foreign { + fs::write(f.exe.join("data/sentinel"), b"race winner")?; + } + } + Ok(()) + }); + assert_eq!(result.directory, f.old); + assert!(result.warning.is_some()); + assert!(!f.exe.join("data/history.txt").exists()); + assert_eq!(f.restart().directory, f.old); + if foreign { + assert_eq!( + fs::read(f.exe.join("data/sentinel")).unwrap(), + b"race winner" + ); + } + f.originals(); + } + } + + #[test] + fn retained_owned_staging_is_reported_and_ignored_on_restart() { + let f = Fixture::new(); + let result = f.resolve(&mut |step| { + if step == "copy:history.txt" || step == "cleanup" { + Err(failure()) + } else { + Ok(()) + } + }); + assert_eq!(result.directory, f.old); + assert!(result + .warning + .unwrap() + .contains("owned staging retained at")); + assert_eq!(fs::read_dir(&f.exe).unwrap().count(), 1); + fs::write(f.old.join("settings.txt"), b"current").unwrap(); + let result = f.restart(); + assert_eq!( + fs::read(result.directory.join("settings.txt")).unwrap(), + b"current" + ); + } + + #[test] + fn source_writes_and_replacement_are_denied_while_copying() { + let f = Fixture::new(); + let result = f.resolve(&mut |step| { + if step == "copy:history.txt" { + assert!(fs::write(f.old.join("settings.txt"), b"changed").is_err()); + assert!(fs::remove_file(f.old.join("history.txt")).is_err()); + } + Ok(()) + }); + assert!(result.warning.is_none()); + f.originals(); + } + + #[test] + fn legacy_junction_is_rejected_without_following_or_copying() { + let f = Fixture::new(); + let junction = f.root.join("junction"); + let output = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(&junction) + .arg(&f.old) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result = resolve_locations(None, Some(f.exe.clone()), Some(junction.clone())); + assert_eq!(result.directory, junction); + assert!(result.warning.is_some()); + assert!(!f.exe.join("data").exists()); + f.originals(); + fs::remove_dir(junction).unwrap(); + } + + #[test] + fn nonregular_source_is_rejected_without_publication() { + let f = Fixture::new(); + fs::remove_file(f.old.join("history.txt")).unwrap(); + fs::create_dir(f.old.join("history.txt")).unwrap(); + let result = f.restart(); + assert_eq!(result.directory, f.old); + assert!(result.warning.is_some()); + assert!(!f.exe.join("data").exists()); + } +} diff --git a/src/win32.rs b/src/win32.rs index 3cdd1fa..5da5ac1 100644 --- a/src/win32.rs +++ b/src/win32.rs @@ -42,9 +42,7 @@ impl Drop for InstanceGuard { // SAFETY: the handle came from CreateMutexW in // `acquire_single_instance` and is closed exactly once, // here. - unsafe { - imp::close_handle(self.handle); - } + imp::close_handle(self.handle); self.handle = std::ptr::null_mut(); } }