Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 6 additions & 19 deletions crates/contextful-context/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
names_file(&self.path, &self.file)
}

/// Take the lock, or `None` while a live holder has it.
pub fn try_acquire(path: &Path) -> Result<Option<FileLock>> {
loop {
Expand Down Expand Up @@ -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<bool> {
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`).
Expand Down
14 changes: 14 additions & 0 deletions crates/contextful-context/tests/integration/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
3 changes: 3 additions & 0 deletions crates/contextful-fs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
137 changes: 131 additions & 6 deletions crates/contextful-fs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -55,6 +55,99 @@ pub fn create_exclusive(tmp: &Path, path: &Path) -> io::Result<bool> {
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<FileIdentity> {
#[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<bool> {
#[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<FileIdentity> {
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::<FILE_ID_INFO>() 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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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<Vec<u16>> {
let mut value: Vec<u16> = 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));
Expand Down
34 changes: 33 additions & 1 deletion crates/contextful-fs/tests/integration/create.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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();
Expand Down