diff --git a/Cargo.lock b/Cargo.lock index 92784ba16..152d7aa69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,7 @@ dependencies = [ "getrandom 0.3.4", "libc", "tempfile", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/contextful-context/src/store.rs b/crates/contextful-context/src/store.rs index 4dfdaf082..558b63e89 100644 --- a/crates/contextful-context/src/store.rs +++ b/crates/contextful-context/src/store.rs @@ -400,6 +400,11 @@ pub struct FileLock { } impl FileLock { + /// Whether the lock path still names this holder's open file. + pub fn names_file(&self) -> Result { + names_file(&self.path, &self.file) + } + /// Take the lock, or `None` while a live holder has it. pub fn try_acquire(path: &Path) -> Result> { loop { @@ -451,25 +456,7 @@ impl Drop for FileLock { /// Whether `path` still names the file `file` opened. fn names_file(path: &Path, file: &fs::File) -> Result { - let at_path = match fs::metadata(path) { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(e) => return Err(ContextError::Io { path: path.to_path_buf(), source: e }), - }; - let held = file.metadata().at(path)?; - Ok(same_file(&at_path, &held)) -} - -#[cfg(unix)] -fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - a.dev() == b.dev() && a.ino() == b.ino() -} - -/// A platform without inode identity removes no open file, so the path names the held one. -#[cfg(not(unix))] -fn same_file(_: &fs::Metadata, _: &fs::Metadata) -> bool { - true + contextful_fs::names_file(path, file).at(path) } /// The store-root directory holding landed bodies (`store.lay-out.landed-blob`). diff --git a/crates/contextful-context/tests/integration/fold.rs b/crates/contextful-context/tests/integration/fold.rs index 0aeb8bbb3..0ffd62b17 100644 --- a/crates/contextful-context/tests/integration/fold.rs +++ b/crates/contextful-context/tests/integration/fold.rs @@ -473,3 +473,17 @@ fn a_lock_stays_held_while_its_holder_lives_however_old_its_file() { drop(next); assert!(!path.exists(), "the last holder leaves no lock file"); } + +#[test] +fn a_renamed_lock_does_not_remove_the_file_that_replaces_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("schema.json.lock"); + let moved = dir.path().join("old.lock"); + let held = FileLock::try_acquire(&path).unwrap().expect("a free lock"); + assert!(held.names_file().unwrap()); + fs::rename(&path, &moved).unwrap(); + fs::write(&path, b"another holder").unwrap(); + assert!(!held.names_file().unwrap()); + drop(held); + assert_eq!(fs::read(&path).unwrap(), b"another holder"); +} diff --git a/crates/contextful-fs/Cargo.toml b/crates/contextful-fs/Cargo.toml index 7ab50027e..0a39a2b89 100644 --- a/crates/contextful-fs/Cargo.toml +++ b/crates/contextful-fs/Cargo.toml @@ -16,6 +16,9 @@ getrandom = "0.3" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + [dev-dependencies] # Ledger measures record their figures (`assurance.measure.record`). contextful-eval = { path = "../../tools/eval" } diff --git a/crates/contextful-fs/src/lib.rs b/crates/contextful-fs/src/lib.rs index a9f4c9a11..f73c542fb 100644 --- a/crates/contextful-fs/src/lib.rs +++ b/crates/contextful-fs/src/lib.rs @@ -3,9 +3,9 @@ //! file or none, and exactly one of several racing creators wins. //! //! The create renames without replacing (`renameat2(RENAME_NOREPLACE)` on Linux, -//! `renamex_np(RENAME_EXCL)` on macOS), falls back to a hard link where the volume refuses -//! the flag, and to a check-then-rename under an advisory lock on the parent directory where -//! it refuses both, as exFAT and FAT32 do. +//! `renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` on Windows), falls back to a hard link +//! where the volume refuses the flag, and to a check-then-rename under an advisory lock on +//! the parent directory where it refuses both, as exFAT and FAT32 do. use std::fs; use std::io::{self, ErrorKind}; @@ -55,6 +55,99 @@ pub fn create_exclusive(tmp: &Path, path: &Path) -> io::Result { create_exclusive_locked(tmp, path) } +/// The volume and complete file identifier of an open handle. Windows identifiers +/// retain all 128 bits required by ReFS. +#[derive(Debug, PartialEq, Eq)] +pub struct FileIdentity { + volume: u64, + file: u128, +} + +/// Identity of the file held open by `file`, independent of its path. +pub fn file_identity(file: &fs::File) -> io::Result { + #[cfg(unix)] + { Ok(unix_identity(&file.metadata()?)) } + #[cfg(windows)] + { windows_file_id(file) } + #[cfg(not(any(unix, windows)))] + { let _ = file; Err(io::Error::from(ErrorKind::Unsupported)) } +} + +#[cfg(unix)] +fn unix_identity(metadata: &fs::Metadata) -> FileIdentity { + use std::os::unix::fs::MetadataExt; + FileIdentity { volume: metadata.dev(), file: u128::from(metadata.ino()) } +} + +/// Whether `path` still names `file`, including after another process renames an open +/// file and creates a replacement at its old path. +pub fn names_file(path: &Path, file: &fs::File) -> io::Result { + #[cfg(unix)] + { + let named = match fs::metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e), + }; + Ok(unix_identity(&named) == file_identity(file)?) + } + #[cfg(windows)] + { + let named = match fs::File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e), + }; + Ok(file_identity(&named)? == file_identity(file)?) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (path, file); + Err(io::Error::from(ErrorKind::Unsupported)) + } +} + +#[cfg(windows)] +fn windows_file_id(file: &fs::File) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED}; + use windows_sys::Win32::Storage::FileSystem::{ + FileIdInfo, GetFileInformationByHandle, GetFileInformationByHandleEx, + GetVolumeInformationByHandleW, BY_HANDLE_FILE_INFORMATION, FILE_ID_INFO, + }; + // SAFETY: FILE_ID_INFO is a plain C value filled through a correctly sized buffer. + let mut full: FILE_ID_INFO = unsafe { std::mem::zeroed() }; + // SAFETY: the open handle and writable buffer remain valid throughout the call. + if unsafe { GetFileInformationByHandleEx(file.as_raw_handle(), FileIdInfo, + (&mut full as *mut FILE_ID_INFO).cast(), std::mem::size_of::() as u32) } != 0 { + return Ok(FileIdentity { volume: full.VolumeSerialNumber, file: u128::from_le_bytes(full.FileId.Identifier) }); + } + let error = io::Error::last_os_error(); + if !error.raw_os_error().is_some_and(|code| [ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED].contains(&(code as u32))) { + return Err(error); + } + // FAT-family volumes may expose only legacy IDs. ReFS never falls back to an ID + // whose truncated width cannot distinguish every file on the volume. + let mut filesystem = [0u16; 32]; + // SAFETY: optional outputs are null; the filesystem buffer has the stated capacity. + if unsafe { GetVolumeInformationByHandleW(file.as_raw_handle(), std::ptr::null_mut(), 0, + std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), filesystem.as_mut_ptr(), filesystem.len() as u32) } == 0 { + return Err(io::Error::last_os_error()); + } + let end = filesystem.iter().position(|c| *c == 0).unwrap_or(filesystem.len()); + if !matches!(String::from_utf16_lossy(&filesystem[..end]).to_ascii_uppercase().as_str(), "FAT" | "FAT32" | "EXFAT") { + return Err(error); + } + // SAFETY: the information structure is a plain C value that the call fills. + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: `file` keeps this valid handle open throughout the call, and `info` is writable. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(FileIdentity { volume: u64::from(info.dwVolumeSerialNumber), + file: (u128::from(info.nFileIndexHigh) << 32) | u128::from(info.nFileIndexLow) }) +} + /// The create for a volume refusing both no-replace renames and hard links: under an /// advisory lock on the parent directory, rename `tmp` onto `path` only while `path` is /// absent. It excludes every other creator going through this primitive; a plain rename @@ -107,7 +200,12 @@ fn flag_refused(e: &io::Error) -> bool { { errno_in(e, &[libc::ENOTSUP, libc::EOPNOTSUPP, libc::EINVAL, libc::ENOSYS]) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED}; + e.raw_os_error().is_some_and(|c| c == ERROR_INVALID_FUNCTION as i32 || c == ERROR_NOT_SUPPORTED as i32) + } + #[cfg(not(any(unix, windows)))] { e.kind() == ErrorKind::Unsupported } @@ -120,7 +218,12 @@ fn link_refused(e: &io::Error) -> bool { { errno_in(e, &[libc::ENOTSUP, libc::EOPNOTSUPP, libc::EPERM, libc::ENOSYS]) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED}; + e.raw_os_error().is_some_and(|c| c == ERROR_INVALID_FUNCTION as i32 || c == ERROR_NOT_SUPPORTED as i32) + } + #[cfg(not(any(unix, windows)))] { e.kind() == ErrorKind::Unsupported } @@ -160,7 +263,29 @@ fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { } } -#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios")))] +#[cfg(windows)] +fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + let wide = |path: &Path| -> io::Result> { + let mut value: Vec = path.as_os_str().encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::from(ErrorKind::InvalidInput)); + } + value.push(0); + Ok(value) + }; + let (from, to) = (wide(from)?, wide(to)?); + // No replacement or cross-volume copy flag: a second creator loses on the same volume. + // SAFETY: both vectors hold NUL-terminated paths alive throughout the call. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 0) } != 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios", windows)))] fn rename_noreplace(_: &Path, _: &Path) -> io::Result<()> { #[cfg(unix)] return Err(io::Error::from_raw_os_error(libc::ENOTSUP)); diff --git a/crates/contextful-fs/tests/integration/create.rs b/crates/contextful-fs/tests/integration/create.rs index cd0ec14f4..6005dc45d 100644 --- a/crates/contextful-fs/tests/integration/create.rs +++ b/crates/contextful-fs/tests/integration/create.rs @@ -1,7 +1,7 @@ //! Exclusive create: one winner, an untouched loser, no staging file left behind, on every //! backend the primitive chooses between. -use contextful_fs::{create_exclusive, create_exclusive_locked, create_new, tmp_sibling}; +use contextful_fs::{create_exclusive, create_exclusive_locked, create_new, names_file, tmp_sibling}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Barrier}; @@ -96,6 +96,38 @@ fn create_new_stages_and_publishes_bytes() { assert_eq!(entries(dir.path()), ["node-id"]); } +#[test] +fn a_renamed_open_file_does_not_identify_its_replacement() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("commit.lock"); + let moved = dir.path().join("old.lock"); + std::fs::write(&path, b"held").unwrap(); + let held = std::fs::File::open(&path).unwrap(); + assert!(names_file(&path, &held).unwrap()); + std::fs::rename(&path, &moved).unwrap(); + assert!(!names_file(&path, &held).unwrap()); + std::fs::write(&path, b"replacement").unwrap(); + assert!(!names_file(&path, &held).unwrap()); + assert_eq!(std::fs::read(&path).unwrap(), b"replacement"); +} + +#[test] +fn open_handles_keep_their_identity_across_rename_and_path_reuse() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("held.lock"); + std::fs::write(&path, b"held").unwrap(); + let held = std::fs::File::open(&path).unwrap(); + let identity = contextful_fs::file_identity(&held).unwrap(); + let again = std::fs::File::open(&path).unwrap(); + assert_eq!(identity, contextful_fs::file_identity(&again).unwrap()); + std::fs::rename(&path, dir.path().join("old.lock")).unwrap(); + std::fs::write(&path, b"replacement").unwrap(); + let replacement = std::fs::File::open(&path).unwrap(); + assert_eq!(identity, contextful_fs::file_identity(&held).unwrap()); + assert_ne!(identity, contextful_fs::file_identity(&replacement).unwrap()); + assert!(!names_file(&path, &held).unwrap()); +} + #[test] fn a_create_into_a_missing_directory_fails_and_leaves_nothing() { let dir = tempfile::tempdir().unwrap();