//! Shared filesystem primitives. use std::path::{Path, PathBuf}; #[cfg(unix)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct FileIdentity { device: u64, inode: u64, } #[cfg(windows)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) enum FileIdentity { Extended { volume: u64, index: [u8; 16] }, Legacy { volume: u32, index: u64 }, } /// Count directory entries referring to this opened file, without path lookup. pub(crate) fn file_link_count(file: &std::fs::File) -> std::io::Result { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; Ok(file.metadata()?.nlink()) } #[cfg(windows)] { Ok(u64::from(windows_file_information(file)?.nNumberOfLinks)) } } pub(crate) fn file_identity(file: &std::fs::File) -> std::io::Result { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let metadata = file.metadata()?; Ok(FileIdentity { device: metadata.dev(), inode: metadata.ino(), }) } #[cfg(windows)] { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::{ FILE_ID_INFO, FileIdInfo, GetFileInformationByHandle, GetFileInformationByHandleEx, }; let mut info = std::mem::MaybeUninit::::uninit(); // SAFETY: the file handle is live and `info` points to writable storage // of the exact FileIdInfo structure requested from Windows. let result = unsafe { GetFileInformationByHandleEx( file.as_raw_handle() as isize, FileIdInfo, info.as_mut_ptr().cast(), std::mem::size_of::() as u32, ) }; if result != 0 { // SAFETY: the successful call initialized the complete structure. let info = unsafe { info.assume_init() }; if info.FileId.Identifier != [0; 16] { return Ok(FileIdentity::Extended { volume: info.VolumeSerialNumber, index: info.FileId.Identifier, }); } } let mut legacy = std::mem::MaybeUninit::uninit(); // SAFETY: the file handle is live and `legacy` points to writable // storage of the exact structure initialized by Windows. let result = unsafe { GetFileInformationByHandle(file.as_raw_handle() as isize, legacy.as_mut_ptr()) }; if result == 0 { return Err(std::io::Error::last_os_error()); } // SAFETY: the successful call initialized the complete structure. let legacy = unsafe { legacy.assume_init() }; let index = (u64::from(legacy.nFileIndexHigh) << 32) | u64::from(legacy.nFileIndexLow); if index == 0 { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "filesystem did not provide a stable file identity", )); } Ok(FileIdentity::Legacy { volume: legacy.dwVolumeSerialNumber, index, }) } } #[cfg(unix)] fn openat_owned( directory: std::os::fd::RawFd, name: &std::ffi::CStr, flags: libc::c_int, mode: libc::mode_t, ) -> std::io::Result { use std::os::fd::FromRawFd; // SAFETY: the directory descriptor and name remain live for the call. A // successful descriptor is newly owned. let raw = unsafe { libc::openat(directory, name.as_ptr(), flags, libc::c_uint::from(mode)) }; if raw < 0 { Err(std::io::Error::last_os_error()) } else { // SAFETY: `raw` is a newly owned descriptor returned by openat. Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(raw) }) } } #[cfg(unix)] fn open_unix_directory(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; Ok(std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(path)? .into()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConfinedParents { Existing, Create, } #[derive(Clone, Copy)] enum ConfinedAccess { Read, Metadata, Append, } #[derive(Debug)] pub(crate) struct ConfinedPath { #[cfg(unix)] root: PathBuf, path: PathBuf, #[cfg(unix)] parent_components: Vec, #[cfg(unix)] parent_dir: std::os::fd::OwnedFd, #[cfg(unix)] name: std::ffi::CString, #[cfg(windows)] directory_handles: Vec, #[cfg(unix)] parent_identity: FileIdentity, } impl ConfinedPath { pub(crate) fn open( root: &Path, path: &Path, parents: ConfinedParents, ) -> std::io::Result { let root = absolute_confined_path(root)?; let path = absolute_confined_path(path)?; let relative = path.strip_prefix(&root).map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{} is outside {}: {error}", path.display(), root.display()), ) })?; let mut components = relative .components() .map(|component| match component { std::path::Component::Normal(value) => Ok(value.to_os_string()), _ => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{} is not a confined descendant", path.display()), )), }) .collect::>>()?; let name = components.pop().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{} does not name a file beneath the root", path.display()), ) })?; Self::open_platform(root, path, components, name, parents) } pub(crate) fn path(&self) -> &Path { &self.path } pub(crate) fn sibling(&self, path: &Path) -> std::io::Result { let path = absolute_confined_path(path)?; if path.parent() != self.path.parent() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "{} is not a sibling of {}", path.display(), self.path.display() ), )); } let name = path .file_name() .map(std::ffi::OsStr::to_os_string) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{} does not name a file", path.display()), ) })?; #[cfg(windows)] let _ = &name; Ok(Self { #[cfg(unix)] root: self.root.clone(), path, #[cfg(unix)] parent_components: self.parent_components.clone(), #[cfg(unix)] parent_dir: self.parent_dir.try_clone()?, #[cfg(unix)] name: confined_cstring(&name)?, #[cfg(windows)] directory_handles: self .directory_handles .iter() .map(std::fs::File::try_clone) .collect::>>()?, #[cfg(unix)] parent_identity: self.parent_identity, }) } pub(crate) fn open_regular(&self) -> std::io::Result { self.validate_namespace()?; self.open_regular_with_access(ConfinedAccess::Read) } pub(crate) fn open_optional_regular(&self) -> std::io::Result> { // A missing namespace is not a missing leaf in the retained parent. self.validate_namespace()?; match self.open_regular_with_access(ConfinedAccess::Read) { Ok(file) => Ok(Some(file)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } /// Open an existing regular file for append without following a leaf link. /// The returned handle, not another pathname open, must supply the resume offset. pub(crate) fn open_optional_append(&self) -> std::io::Result> { self.validate_namespace()?; match self.open_regular_with_access(ConfinedAccess::Append) { Ok(file) => Ok(Some(file)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } pub(crate) fn create_new_regular(&self) -> std::io::Result { self.validate_namespace()?; #[cfg(unix)] { use std::os::fd::AsRawFd; Ok(std::fs::File::from(openat_owned( self.parent_dir.as_raw_fd(), &self.name, libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, // Keep in-progress and failed copies private before writing bytes. 0o600, )?)) } #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, }; let file = std::fs::OpenOptions::new() .read(true) .write(true) .create_new(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(&self.path)?; ensure_windows_regular(&file, &self.path)?; Ok(file) } } pub(crate) fn validate_identity( &self, expected: FileIdentity, ) -> std::io::Result { self.validate_namespace()?; let file = self.open_regular_with_access(ConfinedAccess::Read)?; if file_identity(&file)? != expected { return Err(confined_identity_changed_error(&self.path)); } Ok(file) } /// Retain identity without data access, which would block ReplaceFileW's /// exclusive open of a replacement file. The returned handle cannot read bytes. #[cfg(windows)] pub(crate) fn pin_identity(&self, expected: FileIdentity) -> std::io::Result { self.validate_namespace()?; let file = open_windows_entry_attributes(&self.path)?; ensure_windows_regular(&file, &self.path)?; if file_identity(&file)? != expected { return Err(confined_identity_changed_error(&self.path)); } Ok(file) } pub(crate) fn validate_for_metadata( &self, expected: FileIdentity, ) -> std::io::Result { self.validate_namespace()?; let file = self.open_regular_with_access(ConfinedAccess::Metadata)?; if file_identity(&file)? != expected { return Err(confined_identity_changed_error(&self.path)); } Ok(file) } pub(crate) fn entry_exists(&self) -> std::io::Result { #[cfg(unix)] { use std::os::fd::AsRawFd; let mut stat = std::mem::MaybeUninit::::uninit(); // SAFETY: the retained parent descriptor and NUL-terminated name // remain live, and `stat` is valid writable output storage. let result = unsafe { libc::fstatat( self.parent_dir.as_raw_fd(), self.name.as_ptr(), stat.as_mut_ptr(), libc::AT_SYMLINK_NOFOLLOW, ) }; if result == 0 { return Ok(true); } let error = std::io::Error::last_os_error(); if error.kind() == std::io::ErrorKind::NotFound { Ok(false) } else { Err(error) } } #[cfg(windows)] { match open_windows_entry_attributes(&self.path) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(error), } } } pub(crate) fn sync_parent(&self) -> std::io::Result<()> { #[cfg(unix)] { let file = std::fs::File::from(self.parent_dir.try_clone()?); file.sync_all() } #[cfg(windows)] { Ok(()) } } #[cfg(unix)] pub(crate) fn parent_fd(&self) -> std::os::fd::RawFd { use std::os::fd::AsRawFd; self.parent_dir.as_raw_fd() } #[cfg(unix)] pub(crate) fn name_cstr(&self) -> &std::ffi::CStr { &self.name } fn open_regular_with_access(&self, access: ConfinedAccess) -> std::io::Result { #[cfg(unix)] { use std::os::fd::AsRawFd; let access_flags = match access { ConfinedAccess::Append => libc::O_RDWR | libc::O_APPEND, ConfinedAccess::Read | ConfinedAccess::Metadata => libc::O_RDONLY, }; let flags = access_flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; let file = std::fs::File::from( openat_owned(self.parent_dir.as_raw_fd(), &self.name, flags, 0).map_err( |error| { if error.raw_os_error() == Some(libc::ELOOP) { non_regular_error(&self.path) } else { error } }, )?, ); if !file.metadata()?.file_type().is_file() { return Err(non_regular_error(&self.path)); } Ok(file) } #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_APPEND_DATA, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, }; let probe = open_windows_entry_attributes(&self.path)?; ensure_windows_regular(&probe, &self.path)?; drop(probe); let file = std::fs::OpenOptions::new() .access_mode(match access { ConfinedAccess::Read => FILE_GENERIC_READ | FILE_READ_ATTRIBUTES, ConfinedAccess::Metadata => FILE_GENERIC_READ | FILE_WRITE_ATTRIBUTES, ConfinedAccess::Append => FILE_GENERIC_READ | FILE_APPEND_DATA, }) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(&self.path)?; ensure_windows_regular(&file, &self.path)?; Ok(file) } } fn validate_namespace(&self) -> std::io::Result<()> { #[cfg(unix)] { use std::os::fd::AsRawFd; let mut current = open_unix_directory(&self.root)?; for component in &self.parent_components { let component = confined_cstring(component)?; current = openat_owned( current.as_raw_fd(), &component, libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, 0, )?; } let current_file = std::fs::File::from(current.try_clone()?); if file_identity(¤t_file)? != self.parent_identity { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!("{} changed identity", self.path.display()), )); } Ok(()) } #[cfg(windows)] { Ok(()) } } #[cfg(unix)] fn open_platform( root: PathBuf, path: PathBuf, parent_components: Vec, name: std::ffi::OsString, parents: ConfinedParents, ) -> std::io::Result { use std::os::fd::AsRawFd; let mut parent_dir = open_unix_directory(&root)?; for component in &parent_components { let component = confined_cstring(component)?; parent_dir = match openat_owned( parent_dir.as_raw_fd(), &component, libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, 0, ) { Ok(directory) => directory, Err(error) if parents == ConfinedParents::Create && error.kind() == std::io::ErrorKind::NotFound => { // SAFETY: the retained parent and component remain live. if unsafe { libc::mkdirat(parent_dir.as_raw_fd(), component.as_ptr(), 0o777) } != 0 { let create_error = std::io::Error::last_os_error(); if create_error.kind() != std::io::ErrorKind::AlreadyExists { return Err(create_error); } } else { let parent_file = std::fs::File::from(parent_dir.try_clone()?); parent_file.sync_all()?; } openat_owned( parent_dir.as_raw_fd(), &component, libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, 0, )? } Err(error) => return Err(error), }; } let parent_file = std::fs::File::from(parent_dir.try_clone()?); let parent_identity = file_identity(&parent_file)?; Ok(Self { root, path, parent_components, parent_dir, name: confined_cstring(&name)?, parent_identity, }) } #[cfg(windows)] fn open_platform( root: PathBuf, path: PathBuf, parent_components: Vec, _name: std::ffi::OsString, parents: ConfinedParents, ) -> std::io::Result { let mut directory_handles = open_windows_directory_chain(&root)?; let mut parent_path = root.clone(); for component in &parent_components { parent_path.push(component); if parents == ConfinedParents::Create { match std::fs::create_dir(&parent_path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } } directory_handles.push(open_windows_directory(&parent_path)?); } Ok(Self { path, directory_handles, }) } } fn confined_identity_changed_error(path: &Path) -> std::io::Error { std::io::Error::new( std::io::ErrorKind::NotFound, format!("{} changed identity", path.display()), ) } fn non_regular_error(path: &Path) -> std::io::Error { std::io::Error::new( std::io::ErrorKind::InvalidData, format!("{} is not a regular file", path.display()), ) } #[cfg(unix)] fn confined_cstring(value: &std::ffi::OsStr) -> std::io::Result { use std::os::unix::ffi::OsStrExt; std::ffi::CString::new(value.as_bytes()) .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error)) } #[cfg(windows)] fn open_windows_directory(path: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, }; let file = std::fs::OpenOptions::new() .access_mode(FILE_READ_ATTRIBUTES) .share_mode(FILE_SHARE_READ) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(path)?; let attributes = windows_file_attributes(&file)?; if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 || attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(std::io::Error::new( std::io::ErrorKind::NotADirectory, format!("{} is not a non-reparse directory", path.display()), )); } Ok(file) } #[cfg(windows)] fn open_windows_directory_chain(path: &Path) -> std::io::Result> { let path = absolute_lexical(path)?; let mut current = PathBuf::new(); let mut handles = Vec::new(); for component in path.components() { current.push(component.as_os_str()); if matches!( component, std::path::Component::Prefix(_) | std::path::Component::RootDir ) { continue; } handles.push(open_windows_directory(¤t)?); } if handles.is_empty() { handles.push(open_windows_directory(&path)?); } Ok(handles) } #[cfg(windows)] fn open_windows_entry_attributes(path: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, }; std::fs::OpenOptions::new() .access_mode(FILE_READ_ATTRIBUTES) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(path) } #[cfg(windows)] fn windows_file_information( file: &std::fs::File, ) -> std::io::Result { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; let mut info = std::mem::MaybeUninit::uninit(); // SAFETY: the handle is live and `info` points to writable storage of the // exact structure initialized by GetFileInformationByHandle. let result = unsafe { GetFileInformationByHandle(file.as_raw_handle() as isize, info.as_mut_ptr()) }; if result == 0 { return Err(std::io::Error::last_os_error()); } // SAFETY: the successful call initialized the complete structure. Ok(unsafe { info.assume_init() }) } #[cfg(windows)] fn windows_file_attributes(file: &std::fs::File) -> std::io::Result { Ok(windows_file_information(file)?.dwFileAttributes) } #[cfg(windows)] fn ensure_windows_regular(file: &std::fs::File, path: &Path) -> std::io::Result<()> { use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, }; let attributes = windows_file_attributes(file)?; if attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 || !file.metadata()?.file_type().is_file() { return Err(non_regular_error(path)); } Ok(()) } pub(crate) enum ConfinedFileOpen { Regular(ConfinedRegularFile), Retire, OutsideRoot, } pub(crate) struct ConfinedRegularFile { modified_secs: i64, #[cfg(unix)] parent: std::os::fd::OwnedFd, #[cfg(unix)] name: std::ffi::CString, #[cfg(windows)] file: std::fs::File, } impl ConfinedRegularFile { pub(crate) fn modified_secs(&self) -> i64 { self.modified_secs } pub(crate) fn remove(self) -> std::io::Result<()> { #[cfg(unix)] { use std::os::fd::AsRawFd; // SAFETY: `parent` is a live directory descriptor, `name` is // NUL-terminated, and both remain valid for the syscall. let result = unsafe { libc::unlinkat(self.parent.as_raw_fd(), self.name.as_ptr(), 0) }; if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } #[cfg(windows)] { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::{ FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle, }; let disposition = FILE_DISPOSITION_INFO { DeleteFile: 1 }; // SAFETY: the file handle is live and was opened with DELETE // access. `disposition` has the layout and size required by // FileDispositionInfo and outlives the call. let result = unsafe { SetFileInformationByHandle( self.file.as_raw_handle() as isize, FileDispositionInfo, std::ptr::from_ref(&disposition).cast(), std::mem::size_of_val(&disposition) as u32, ) }; if result != 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } } } /// Open a regular file beneath `root` without allowing a directory-link swap /// to redirect a later removal. The returned guard retains the verified /// directory or file handle through [`ConfinedRegularFile::remove`]. pub(crate) fn open_confined_regular_file( root: &Path, path: &Path, ) -> std::io::Result { let Ok(relative) = path.strip_prefix(root) else { return Ok(ConfinedFileOpen::OutsideRoot); }; let components = relative .components() .map(|component| match component { std::path::Component::Normal(value) => Some(value), _ => None, }) .collect::>>(); let Some(components) = components else { return Ok(ConfinedFileOpen::Retire); }; if components.is_empty() { return Ok(ConfinedFileOpen::Retire); } open_confined_regular_file_platform(root, &components) } #[cfg(unix)] fn open_confined_regular_file_platform( root: &Path, components: &[&std::ffi::OsStr], ) -> std::io::Result { use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; fn owned_fd(raw: libc::c_int) -> std::io::Result { if raw < 0 { return Err(std::io::Error::last_os_error()); } // SAFETY: a nonnegative descriptor returned by open/openat is newly // owned by this function and is transferred exactly once. Ok(unsafe { OwnedFd::from_raw_fd(raw) }) } fn path_target_changed(error: &std::io::Error) -> bool { matches!( error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory ) || error.raw_os_error() == Some(libc::ELOOP) } fn cstring(value: &std::ffi::OsStr) -> std::io::Result { std::ffi::CString::new(value.as_bytes()) .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error)) } let root = cstring(root.as_os_str())?; // SAFETY: `root` is a live NUL-terminated path. No borrowed pointer is // retained after the syscall. let raw = unsafe { libc::open( root.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, ) }; let mut parent = match owned_fd(raw) { Ok(fd) => fd, Err(error) if path_target_changed(&error) => return Ok(ConfinedFileOpen::Retire), Err(error) => return Err(error), }; let Some((file_name, parent_components)) = components.split_last() else { return Ok(ConfinedFileOpen::Retire); }; for component in parent_components { let component = cstring(component)?; // SAFETY: `parent` is live and `component` is NUL-terminated. The // returned descriptor, when successful, is newly owned. let raw = unsafe { libc::openat( parent.as_raw_fd(), component.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, ) }; parent = match owned_fd(raw) { Ok(fd) => fd, Err(error) if path_target_changed(&error) => return Ok(ConfinedFileOpen::Retire), Err(error) => return Err(error), }; } let name = cstring(file_name)?; let mut stat = std::mem::MaybeUninit::::uninit(); // SAFETY: `parent` and `name` are live, and `stat` points to writable // storage of the exact type the kernel initializes on success. let result = unsafe { libc::fstatat( parent.as_raw_fd(), name.as_ptr(), stat.as_mut_ptr(), libc::AT_SYMLINK_NOFOLLOW, ) }; if result != 0 { let error = std::io::Error::last_os_error(); return if path_target_changed(&error) { Ok(ConfinedFileOpen::Retire) } else { Err(error) }; } // SAFETY: successful fstatat initialized every field of `stat`. let stat = unsafe { stat.assume_init() }; if stat.st_mode & libc::S_IFMT != libc::S_IFREG { return Ok(ConfinedFileOpen::Retire); } Ok(ConfinedFileOpen::Regular(ConfinedRegularFile { modified_secs: stat.st_mtime.max(0), parent, name, })) } #[cfg(windows)] fn open_confined_regular_file_platform( root: &Path, components: &[&std::ffi::OsStr], ) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, GetFileInformationByHandle, }; fn open(path: &Path, directory: bool) -> std::io::Result { use windows_sys::Win32::Storage::FileSystem::{ DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, }; let access = FILE_READ_ATTRIBUTES | if directory { 0 } else { DELETE }; let flags = FILE_FLAG_OPEN_REPARSE_POINT | if directory { FILE_FLAG_BACKUP_SEMANTICS } else { 0 }; std::fs::OpenOptions::new() .access_mode(access) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .custom_flags(flags) .open(path) } fn info(file: &std::fs::File) -> std::io::Result { let mut info = std::mem::MaybeUninit::uninit(); // SAFETY: the handle is live and `info` points to writable storage of // the exact structure initialized by GetFileInformationByHandle. let result = unsafe { GetFileInformationByHandle(file.as_raw_handle() as isize, info.as_mut_ptr()) }; if result == 0 { return Err(std::io::Error::last_os_error()); } // SAFETY: the successful call initialized the full structure. Ok(unsafe { info.assume_init() }.dwFileAttributes) } fn final_path(file: &std::fs::File) -> std::io::Result { use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW; let mut buffer = vec![0_u16; 512]; loop { // SAFETY: the handle and buffer are live, and the buffer length // matches the writable UTF-16 storage supplied to Windows. let written = unsafe { GetFinalPathNameByHandleW( file.as_raw_handle() as isize, buffer.as_mut_ptr(), buffer.len() as u32, 0, ) } as usize; if written == 0 { return Err(std::io::Error::last_os_error()); } if written < buffer.len() { buffer.truncate(written); return Ok(std::ffi::OsString::from_wide(&buffer).into()); } buffer.resize(written + 1, 0); } } let root_file = match open(root, true) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(ConfinedFileOpen::Retire); } Err(error) => return Err(error), }; let root_attributes = info(&root_file)?; if root_attributes & FILE_ATTRIBUTE_DIRECTORY == 0 || root_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Ok(ConfinedFileOpen::Retire); } let root_final = final_path(&root_file)?; let path = components .iter() .fold(root.to_path_buf(), |mut path, part| { path.push(part); path }); let file = match open(&path, false) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(ConfinedFileOpen::Retire); } Err(error) => return Err(error), }; let attributes = info(&file)?; if attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 { return Ok(ConfinedFileOpen::Retire); } let file_final = final_path(&file)?; let expected_final = components.iter().fold(root_final, |mut path, part| { path.push(part); path }); if file_final != expected_final { return Ok(ConfinedFileOpen::Retire); } let modified_secs = file .metadata()? .modified()? .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(0); Ok(ConfinedFileOpen::Regular(ConfinedRegularFile { modified_secs, file, })) } /// Normalize path spelling for platform-specific collision checks. pub(crate) fn normalized_path(path: &Path) -> std::borrow::Cow<'_, str> { let s = path.to_string_lossy(); #[cfg(any(target_os = "macos", target_os = "windows"))] { std::borrow::Cow::Owned(s.to_ascii_lowercase()) } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { s } } /// Build the shared absolute ownership key without accepting parent traversal. pub(crate) fn confined_path_key(path: &Path) -> std::io::Result { Ok(normalized_path(&absolute_confined_path(path)?).into_owned()) } /// Rebase a reserved destination using platform ownership-key component rules. /// Parent traversal is rejected before comparison; filesystem opens still use /// the existing no-follow policy beneath the current root spelling. pub(crate) fn reserved_path_under_root(root: &Path, path: &Path) -> std::io::Result { let absolute_root = absolute_confined_path(root)?; let absolute_path = absolute_confined_path(path)?; let root_key = confined_path_key(&absolute_root)?; let path_key = confined_path_key(&absolute_path)?; if Path::new(&path_key) .strip_prefix(Path::new(&root_key)) .is_err() || absolute_path.components().count() <= absolute_root.components().count() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "Reserved destination is outside the current download root", )); } let relative: PathBuf = absolute_path .components() .skip(absolute_root.components().count()) .collect(); let rebased = root.join(relative); if confined_path_key(&rebased)? != path_key { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "Rebased destination does not match its reservation", )); } Ok(rebased) } /// Resolve a confined path without changing parent-component semantics. /// /// Returns `InvalidInput` for any `..` component: removing it lexically can /// bypass a symlink and select a different file before no-follow traversal. pub(crate) fn absolute_confined_path(path: &Path) -> std::io::Result { if path .components() .any(|component| component == std::path::Component::ParentDir) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "Confined path must not contain parent components: {}", path.display() ), )); } absolute_lexical(path) } /// Resolve `path` against the current directory and remove lexical `.` and /// `..` components without following filesystem symlinks. pub(crate) fn absolute_lexical(path: &Path) -> std::io::Result { let absolute = std::path::absolute(path)?; let mut normalized = std::path::PathBuf::new(); for component in absolute.components() { match component { std::path::Component::CurDir => {} std::path::Component::ParentDir => { normalized.pop(); } _ => normalized.push(component.as_os_str()), } } Ok(normalized) } /// Remove `path`, treating `NotFound` as success and logging any other /// error at `warn!`. Use this in cleanup paths (`.part` cleanup, corrupt /// session-file deletion, EXDEV unwind) where a previous `let _ =` was /// silently dropping errors that violated the "no silent failures" /// invariant. /// /// Used by both the default XMP writer and the native no-`xmp` EXIF writer. /// The async sibling `log_remove_async` is available for callers already on a /// tokio task. pub(crate) fn log_remove(path: &Path) { if let Err(e) = std::fs::remove_file(path) && e.kind() != std::io::ErrorKind::NotFound { tracing::warn!( path = %path.display(), error = %e, "Failed to remove file during cleanup" ); } } /// Async sibling of [`log_remove`] for callers already on a tokio task; /// uses `tokio::fs::remove_file` so it doesn't block a runtime worker. pub(crate) async fn log_remove_async(path: &Path) { if let Err(e) = tokio::fs::remove_file(path).await && e.kind() != std::io::ErrorKind::NotFound { tracing::warn!( path = %path.display(), error = %e, "Failed to remove file during cleanup" ); } } /// Open `path`'s parent directory and `fsync` it so a preceding `rename`'s /// directory entry survives a power loss. Unix-only; on Windows this is a /// no-op because the std API doesn't expose a directory handle for fsync. /// /// Errors from the open or sync are returned to the caller. Callers that /// want best-effort durability without bubbling the error should log and /// drop it themselves. pub(crate) fn fsync_parent_dir(path: &Path) -> std::io::Result<()> { #[cfg(unix)] { let parent = path.parent().unwrap_or(Path::new(".")); let dir = std::fs::File::open(parent)?; dir.sync_all() } #[cfg(not(unix))] { let _ = path; Ok(()) } } /// Async wrapper around [`fsync_parent_dir`] that runs the blocking /// syscall on the blocking pool and swallows every error class with a /// warn. Use when the caller has already committed to the rename /// being durable enough on its own (the bytes are at `path`, the /// metadata flush is best-effort). pub(crate) async fn fsync_parent_dir_async_best_effort(path: &Path) { let path_buf = path.to_path_buf(); match tokio::task::spawn_blocking(move || fsync_parent_dir(&path_buf)).await { Ok(Ok(())) => {} Ok(Err(e)) => tracing::warn!( path = %path.display(), error = %e, "parent-dir fsync failed; durability of rename not guaranteed" ), Err(join_err) => tracing::warn!( path = %path.display(), error = %join_err, "parent-dir fsync task panicked; durability of rename not guaranteed" ), } } /// Install `src` at `dst` atomically. /// /// Prefers `rename` (atomic on the same device); on EXDEV, copies to a /// sibling of `dst` on the destination device and renames that sibling /// into place so a mid-copy crash can't expose a half-written `dst`. /// /// `src`'s data is fsynced before the rename and `dst`'s parent directory /// is fsynced after, so a power loss between the rename returning and the /// kernel committing data + directory blocks can't leave `dst` pointing /// at an uninitialised file or vanish on the next mount. pub(crate) fn atomic_install(src: &Path, dst: &Path) -> std::io::Result<()> { atomic_install_with(src, dst, |s, d| std::fs::rename(s, d)) } /// fsync `path` if it exists. Treats NotFound as a no-op so callers don't /// have to special-case the EXDEV path (where the original src was already /// consumed by a copy). /// /// Unix-only. On Windows `std::fs::File::open` returns a read-only handle /// and `FlushFileBuffers` requires write access, so the natural /// implementation here returns `ERROR_ACCESS_DENIED`. NTFS journals /// metadata anyway, so the data-blocks-vs-rename ordering risk this /// guards on Linux doesn't manifest the same way; treat the Windows path /// as a no-op rather than carry a fragile reopen-with-write workaround. fn fsync_file(path: &Path) -> std::io::Result<()> { #[cfg(unix)] { match std::fs::File::open(path) { Ok(f) => f.sync_all(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e), } } #[cfg(not(unix))] { let _ = path; Ok(()) } } /// Test hook: like [`atomic_install`] but accepts an injectable `rename` so /// tests can force the EXDEV fallback without needing a real cross-device /// setup. Only the initial `src -> dst` rename is injected; the fallback's /// `sibling -> dst` rename is plain `std::fs::rename` (same-device, can't /// fail with EXDEV). fn atomic_install_with(src: &Path, dst: &Path, rename: R) -> std::io::Result<()> where R: FnOnce(&Path, &Path) -> std::io::Result<()>, { fsync_file(src)?; if let Err(rename_err) = rename(src, dst) { let ext = dst.extension().and_then(|e| e.to_str()).unwrap_or("tmp"); let dst_sibling = dst.with_extension(format!("{ext}.kei-xdev-tmp-{}", std::process::id())); if let Err(copy_err) = std::fs::copy(src, &dst_sibling) { let _ = std::fs::remove_file(src); tracing::warn!( src = %src.display(), dst = %dst.display(), rename_err = %rename_err, copy_err = %copy_err, "rename failed and cross-device copy also failed" ); return Err(rename_err); } // Fsync the sibling we just copied before renaming it into place. fsync_file(&dst_sibling)?; if let Err(final_err) = std::fs::rename(&dst_sibling, dst) { let _ = std::fs::remove_file(&dst_sibling); let _ = std::fs::remove_file(src); return Err(final_err); } let _ = std::fs::remove_file(src); } if let Err(e) = fsync_parent_dir(dst) { tracing::warn!( path = %dst.display(), error = %e, "fsync of parent directory failed after atomic_install" ); return Err(e); } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::io; #[test] fn issue_770_recovery_reserved_rebase_preserves_component_confinement() { let root = tempfile::tempdir().unwrap(); let saved = root.path().join("Album/pending.jpg"); let current = root.path().join("."); let replay = reserved_path_under_root(¤t, &saved).unwrap(); assert_eq!( confined_path_key(&replay).unwrap(), confined_path_key(&saved).unwrap() ); assert!(reserved_path_under_root(root.path(), root.path()).is_err()); assert!( reserved_path_under_root(root.path(), &root.path().join("../outside.jpg")).is_err() ); let outside = root.path().with_extension("sibling").join("pending.jpg"); assert!(reserved_path_under_root(root.path(), &outside).is_err()); } #[cfg(any(target_os = "macos", target_os = "windows"))] #[test] fn issue_770_recovery_reserved_rebase_accepts_case_equivalent_root_only() { let root = tempfile::tempdir().unwrap(); let current = root.path().join("Photos"); let saved = root.path().join("pHOTOS/Album/Pending.jpg"); let replay = reserved_path_under_root(¤t, &saved).unwrap(); assert_eq!(replay, current.join("Album/Pending.jpg")); assert_eq!( confined_path_key(&replay).unwrap(), confined_path_key(&saved).unwrap() ); assert!( reserved_path_under_root(¤t, &root.path().join("Photos-other/pending.jpg")) .is_err() ); } #[cfg(windows)] #[test] fn confined_identity_pin_allows_exclusive_replacement_access() { use std::os::windows::fs::OpenOptionsExt; let root = tempfile::tempdir().unwrap(); let path = root.path().join("replacement.part"); std::fs::write(&path, b"replacement").unwrap(); let confined = ConfinedPath::open(root.path(), &path, ConfinedParents::Existing).unwrap(); let reader = confined.open_regular().unwrap(); let identity = file_identity(&reader).unwrap(); let mut exclusive = std::fs::OpenOptions::new(); exclusive.read(true).write(true).share_mode(0); assert!(exclusive.open(&path).is_err()); let pinned = confined.pin_identity(identity).unwrap(); drop(reader); let replacement = exclusive.open(&path).unwrap(); assert_eq!(file_identity(&replacement).unwrap(), identity); assert_eq!(file_identity(&pinned).unwrap(), identity); } #[test] fn confined_append_requires_regular_file_and_preserves_prefix() { let root = tempfile::tempdir().unwrap(); let path = root.path().join("resume.part"); let confined = ConfinedPath::open(root.path(), &path, ConfinedParents::Existing).unwrap(); assert!(confined.open_optional_append().unwrap().is_none()); std::fs::create_dir(&path).unwrap(); assert!(confined.open_optional_append().is_err()); std::fs::remove_dir(&path).unwrap(); std::fs::write(&path, b"prefix").unwrap(); let mut file = confined.open_optional_append().unwrap().unwrap(); assert_eq!(file.metadata().unwrap().len(), 6); std::io::Write::write_all(&mut file, b" suffix").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"prefix suffix"); } #[test] fn confined_path_rejects_parent_components() { let root = tempfile::tempdir().unwrap(); let ambiguous_root = root.path().join("child/.."); let path = root.path().join("file.jpg"); for (root_path, file_path) in [ (ambiguous_root.as_path(), path.as_path()), (root.path(), root.path().join("child/../file.jpg").as_path()), ] { let error = ConfinedPath::open(root_path, file_path, ConfinedParents::Create).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } let confined = ConfinedPath::open(root.path(), &path, ConfinedParents::Existing).unwrap(); assert_eq!( confined .sibling(&root.path().join("child/../sibling.jpg")) .unwrap_err() .kind(), io::ErrorKind::InvalidInput ); assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); } #[test] fn confined_path_rejects_outside_root() { let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let destination = outside.path().join("nested/file.jpg"); let error = ConfinedPath::open(root.path(), &destination, ConfinedParents::Create).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(!destination.exists()); } #[test] fn confined_path_creates_missing_nested_parents() { let root = tempfile::tempdir().unwrap(); let destination = root.path().join("one/two/three/file.jpg"); let confined = ConfinedPath::open(root.path(), &destination, ConfinedParents::Create).unwrap(); let mut file = confined.create_new_regular().unwrap(); std::io::Write::write_all(&mut file, b"media").unwrap(); file.sync_all().unwrap(); let identity = file_identity(&file).unwrap(); confined.validate_identity(identity).unwrap(); confined.sync_parent().unwrap(); assert_eq!(std::fs::read(destination).unwrap(), b"media"); } #[cfg(unix)] #[test] fn confined_path_rejects_linked_parent_without_external_write() { use std::os::unix::fs::symlink; let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let linked = root.path().join("linked"); symlink(outside.path(), &linked).unwrap(); assert!( ConfinedPath::open( root.path(), &linked.join("file.jpg"), ConfinedParents::Create ) .is_err() ); assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); } #[cfg(unix)] #[test] fn confined_path_uses_retained_parent_and_detects_replacement() { use std::os::unix::fs::symlink; let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let parent = root.path().join("parent"); let retained = root.path().join("retained"); std::fs::create_dir(&parent).unwrap(); let destination = parent.join("file.jpg"); let confined = ConfinedPath::open(root.path(), &destination, ConfinedParents::Existing).unwrap(); std::fs::rename(&parent, &retained).unwrap(); symlink(outside.path(), &parent).unwrap(); assert!(confined.create_new_regular().is_err()); assert!(!retained.join("file.jpg").exists()); assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); } #[test] fn same_device_rename_succeeds() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.tmp"); let dst = dir.path().join("dst.json"); std::fs::write(&src, b"hello").unwrap(); atomic_install(&src, &dst).expect("atomic_install"); assert!(!src.exists(), "src must be consumed by the rename"); assert_eq!(std::fs::read(&dst).unwrap(), b"hello"); for entry in std::fs::read_dir(dir.path()).unwrap().flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); assert!( !name.contains("kei-xdev-tmp"), "unexpected sidecar tmp {name}" ); } } #[test] fn missing_src_returns_err_without_touching_dst() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("nope.tmp"); let dst = dir.path().join("dst.json"); assert!(atomic_install(&src, &dst).is_err()); assert!(!dst.exists(), "dst must not be created on failure"); } /// Forces the rename to fail with a cross-device error, exercising the /// copy-to-sibling-then-rename fallback end-to-end. After the fallback, /// `dst` must contain the source bytes, `src` is removed, and no /// `.kei-xdev-tmp-*` file remains. #[test] fn exdev_fallback_installs_dst_atomically() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.tmp"); let dst = dir.path().join("dst.json"); std::fs::write(&src, b"xdev-payload").unwrap(); let force_exdev = |_s: &Path, _d: &Path| -> io::Result<()> { Err(io::Error::new( io::ErrorKind::CrossesDevices, "simulated EXDEV", )) }; atomic_install_with(&src, &dst, force_exdev).expect("EXDEV fallback should succeed"); assert!( !src.exists(), "src must be removed after successful fallback" ); assert_eq!(std::fs::read(&dst).unwrap(), b"xdev-payload"); for entry in std::fs::read_dir(dir.path()).unwrap().flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); assert!( !name.contains("kei-xdev-tmp"), "EXDEV fallback must clean up its sibling tmp: {name}" ); } } /// `fsync_parent_dir` returns `Ok(())` for an /// extant directory on every supported platform. On Unix it actually /// opens and fsyncs the parent; on Windows it's a documented no-op. The /// test pins both platforms to "doesn't error" so a future regression /// that drops the cfg gate or changes the open mode surfaces here. #[test] fn fsync_parent_dir_succeeds_for_extant_path() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("anchor.txt"); std::fs::write(&file, b"x").unwrap(); fsync_parent_dir(&file).expect("fsync_parent_dir should succeed"); } /// `fsync_parent_dir` on Unix surfaces a NotFound when the parent itself /// is missing; on other platforms it's a no-op and returns Ok. Pinning /// the Unix branch makes accidental swallowing of the error visible. #[cfg(unix)] #[test] fn fsync_parent_dir_unix_errors_when_parent_missing() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("nope/sub/file.txt"); let err = fsync_parent_dir(&file).expect_err("missing parent should error on unix"); assert_eq!(err.kind(), io::ErrorKind::NotFound); } /// Happy-path coverage: a same-device install of a freshly written /// src succeeds end-to-end with the new fsync calls in the chain. /// Regression guard if a future refactor drops the fsync of src or the /// parent fsync and breaks the call (e.g. by accidentally borrowing a /// closed File past sync_all). #[test] fn atomic_install_round_trip_fsyncs_without_error() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.tmp"); let dst = dir.path().join("dst.json"); let payload = b"durable payload"; std::fs::write(&src, payload).unwrap(); atomic_install(&src, &dst).expect("atomic_install with fsync should succeed"); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap(), payload); } /// If the initial rename fails and the cross-device copy also fails /// (e.g. dst parent is read-only), `src` is removed and the original /// rename error is returned; `dst` is never created. #[test] fn exdev_fallback_with_copy_failure_surfaces_rename_err() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.tmp"); let nonexistent_parent = dir.path().join("no_such_dir"); let dst = nonexistent_parent.join("dst.json"); std::fs::write(&src, b"payload").unwrap(); let force_exdev = |_s: &Path, _d: &Path| -> io::Result<()> { Err(io::Error::new( io::ErrorKind::CrossesDevices, "simulated EXDEV", )) }; let err = atomic_install_with(&src, &dst, force_exdev).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::CrossesDevices); assert!(!dst.exists(), "dst must not be created when fallback fails"); assert!( !src.exists(), "src must be cleaned up even when fallback fails" ); } }