mod directory;
mod lock_wait;
pub use directory::{DirectoryCreationError, admit_directory, create_private_directory};
pub use lock_wait::{lock_exclusive, lock_exclusive_with_wait, lock_shared, unlock};
#[cfg(unix)]
mod retained;
#[cfg(unix)]
pub use retained::{create_retained_at, publish_retained_at};
mod named;
pub use named::{NamedWriteError, write_named_with};
mod stream;
pub use stream::{PublicationMode, WriteOptions, write_validated_with, write_with};
#[cfg(unix)]
pub use stream::{write_at_validated_with, write_at_with};
#[cfg(test)]
mod tests;
use std::{fmt, fs, io, path::Path};
#[derive(Debug)]
pub enum RegularFileLockError {
NotRegular,
Io(io::Error),
Publication(NamedWriteError<io::Error>),
#[cfg(windows)]
UnsupportedPlatform,
}
impl fmt::Display for RegularFileLockError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotRegular => formatter.write_str("lock target is not a regular file"),
Self::Io(_) => formatter.write_str("regular file lock I/O failed"),
Self::Publication(_) => formatter.write_str("durable lock-file creation failed"),
#[cfg(windows)]
Self::UnsupportedPlatform => formatter.write_str("regular file locking is unsupported"),
}
}
}
impl std::error::Error for RegularFileLockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(source) => Some(source),
Self::Publication(source) => Some(source),
Self::NotRegular => None,
#[cfg(windows)]
Self::UnsupportedPlatform => None,
}
}
}
impl From<RegularFileLockError> for io::Error {
fn from(error: RegularFileLockError) -> Self {
match error {
RegularFileLockError::Io(source) => source,
other @ RegularFileLockError::NotRegular => Self::other(other),
other @ RegularFileLockError::Publication(_) => Self::other(other),
#[cfg(windows)]
other @ RegularFileLockError::UnsupportedPlatform => Self::other(other),
}
}
}
const REPLACE_OPTIONS: WriteOptions = WriteOptions {
mode: PublicationMode::Replace,
permissions: 0o666,
};
const CREATE_NEW_OPTIONS: WriteOptions = WriteOptions {
mode: PublicationMode::CreateNew,
permissions: 0o666,
};
const CREATE_PRIVATE_OPTIONS: WriteOptions = WriteOptions {
mode: PublicationMode::CreateNew,
permissions: 0o600,
};
pub fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), NamedWriteError<io::Error>> {
commit_bytes(path, bytes, REPLACE_OPTIONS)
}
pub fn create_new_bytes_with_parents(
path: &Path,
bytes: &[u8],
) -> Result<(), NamedWriteError<io::Error>> {
commit_bytes(path, bytes, CREATE_NEW_OPTIONS)
}
pub fn create_private_bytes_with_parents(
path: &Path,
bytes: &[u8],
) -> Result<(), NamedWriteError<io::Error>> {
commit_bytes(path, bytes, CREATE_PRIVATE_OPTIONS)
}
pub fn prepare_private_parents(parent: &Path) -> io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
{
supported::prepare_private_parents(parent)
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
{
let _ = parent;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"durable private parent creation is unsupported",
))
}
}
pub fn lock_regular_file_with_parents(path: &Path) -> Result<fs::File, RegularFileLockError> {
let file = open_regular_lock_file_with_parents(path)?;
#[cfg(not(windows))]
rustix::fs::flock(&file, rustix::fs::FlockOperation::LockExclusive)
.map_err(errno_to_lock_error)?;
Ok(file)
}
pub fn try_lock_regular_file_with_parents(path: &Path) -> Result<fs::File, RegularFileLockError> {
let file = open_regular_lock_file_with_parents(path)?;
#[cfg(not(windows))]
rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive)
.map_err(errno_to_lock_error)?;
Ok(file)
}
pub fn open_regular_lock_file_with_parents(path: &Path) -> Result<fs::File, RegularFileLockError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
match create_new_bytes_with_parents(path, &[]) {
Ok(()) => {}
Err(NamedWriteError::BeforePublication {
source,
cleanup_error: None,
}) if source.kind() == io::ErrorKind::AlreadyExists => {}
Err(source) => return Err(RegularFileLockError::Publication(source)),
}
fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?
}
Err(source) => return Err(RegularFileLockError::Io(source)),
};
if !metadata.file_type().is_file() {
return Err(RegularFileLockError::NotRegular);
}
#[cfg(not(windows))]
{
open_regular_lock_descriptor(path, None)
}
#[cfg(windows)]
{
Err(RegularFileLockError::UnsupportedPlatform)
}
}
pub fn open_private_regular_lock_file(path: &Path) -> Result<fs::File, RegularFileLockError> {
#[cfg(not(windows))]
{
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => return Err(RegularFileLockError::NotRegular),
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => return Err(RegularFileLockError::Io(source)),
}
open_regular_lock_descriptor(path, Some(rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR))
}
#[cfg(windows)]
{
let _ = path;
Err(RegularFileLockError::UnsupportedPlatform)
}
}
#[cfg(not(windows))]
fn open_regular_lock_descriptor(
path: &Path,
create_permissions: Option<rustix::fs::Mode>,
) -> Result<fs::File, RegularFileLockError> {
use rustix::fs::{FileType, Mode, OFlags, fstat, open};
let mut flags = OFlags::RDWR | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
if create_permissions.is_some() {
flags |= OFlags::CREATE;
}
let fd = open(path, flags, create_permissions.unwrap_or(Mode::empty()))
.map_err(errno_to_lock_error)?;
let metadata = fstat(&fd).map_err(errno_to_lock_error)?;
if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
return Err(RegularFileLockError::NotRegular);
}
Ok(fs::File::from(fd))
}
#[cfg(not(windows))]
fn errno_to_lock_error(source: rustix::io::Errno) -> RegularFileLockError {
RegularFileLockError::Io(io::Error::from_raw_os_error(source.raw_os_error()))
}
fn commit_bytes(
path: &Path,
bytes: &[u8],
options: WriteOptions,
) -> Result<(), NamedWriteError<io::Error>> {
write_with(path, options, |file| std::io::Write::write_all(file, bytes))
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
mod supported {
use super::{NamedWriteError, PublicationMode, WriteOptions};
use std::{
ffi::{OsStr, OsString},
fs, io,
path::{Component, Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use rustix::{
fd::{AsFd, BorrowedFd, OwnedFd},
fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
};
use std::os::unix::fs::DirBuilderExt;
const TEMP_ATTEMPTS: usize = 64;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum FileCommitStep {
ParentDirectoryCreate,
CreatedDirectorySync,
CreatedDirectoryParentSync,
TemporaryFileCreate,
TemporaryFileWrite,
TemporaryFileSync,
Publication,
FinalParentSync,
}
#[cfg(test)]
pub(super) fn commit_with_hook(
path: &Path,
bytes: &[u8],
options: WriteOptions,
before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
) -> Result<(), NamedWriteError<io::Error>> {
commit_path_with_options(
path,
options,
|_, file| std::io::Write::write_all(file, bytes),
None::<(bool, fn(&Path, &()) -> io::Result<()>)>,
before,
)
}
pub(super) fn commit_path_with_options<T, E>(
path: &Path,
options: WriteOptions,
produce: impl FnOnce(&Path, &mut fs::File) -> Result<T, E>,
admit: Option<(bool, impl FnOnce(&Path, &T) -> Result<(), E>)>,
mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
) -> Result<T, NamedWriteError<E>> {
validate_permissions(options.permissions).map_err(NamedWriteError::before)?;
let (parent, file_name) = split_target(path).map_err(NamedWriteError::before)?;
create_parent_hierarchy(parent, &mut before, false).map_err(NamedWriteError::before)?;
let parent_fd = open_directory(parent).map_err(NamedWriteError::before)?;
commit_at_with_hook(
parent_fd.as_fd(),
Some(parent),
file_name,
options,
produce,
admit,
before,
)
}
pub(super) fn commit_at_with_hook<T, E>(
parent_fd: BorrowedFd<'_>,
parent_path: Option<&Path>,
file_name: &OsStr,
options: WriteOptions,
produce: impl FnOnce(&Path, &mut fs::File) -> Result<T, E>,
mut admit: Option<(bool, impl FnOnce(&Path, &T) -> Result<(), E>)>,
mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
) -> Result<T, NamedWriteError<E>> {
validate_permissions(options.permissions).map_err(NamedWriteError::before)?;
validate_file_name(file_name).map_err(NamedWriteError::before)?;
let metadata = unix_fs::fstat(parent_fd)
.map_err(|error| NamedWriteError::before(errno_to_io(error)))?;
if unix_fs::FileType::from_raw_mode(metadata.st_mode) != unix_fs::FileType::Directory {
return Err(NamedWriteError::before(io::Error::from(
io::ErrorKind::NotADirectory,
)));
}
let parent = parent_path.unwrap_or_else(|| Path::new(""));
let path = parent.join(file_name);
let permissions =
native_permissions(options.permissions).map_err(NamedWriteError::before)?;
let (temp_name, temp_path, mut temp_file) =
create_sibling_temp(&parent_fd, parent, file_name, permissions, &mut before)
.map_err(NamedWriteError::before)?;
let mut produce = Some(produce);
let mut value = None;
let published = (|| {
before(FileCommitStep::TemporaryFileWrite, &temp_path)
.map_err(NamedWriteError::before)?;
value = Some(
produce.take().expect("producer runs once")(&temp_path, &mut temp_file).map_err(
|source| NamedWriteError::Producer {
source,
cleanup_error: None,
},
)?,
);
before(FileCommitStep::TemporaryFileSync, &temp_path)
.map_err(NamedWriteError::before)?;
verify_staging(&parent_fd, parent_path, &temp_name, &temp_file)
.map_err(NamedWriteError::before)?;
temp_file.sync_all().map_err(NamedWriteError::before)?;
if let Some((close_writer, admit)) = admit.take() {
if close_writer {
let read_only =
retain_read_only(&parent_fd, parent_path, &temp_name, &temp_file)
.map_err(NamedWriteError::before)?;
drop(std::mem::replace(&mut temp_file, read_only));
}
admit(&temp_path, value.as_ref().expect("production succeeded")).map_err(
|source| NamedWriteError::Producer {
source,
cleanup_error: None,
},
)?;
}
before(FileCommitStep::Publication, &path).map_err(NamedWriteError::before)?;
verify_staging(&parent_fd, parent_path, &temp_name, &temp_file)
.map_err(NamedWriteError::before)?;
match options.mode {
PublicationMode::Replace => {
unix_fs::renameat(parent_fd, &temp_name, parent_fd, file_name)
.map_err(|error| NamedWriteError::before(errno_to_io(error)))
}
PublicationMode::CreateNew => {
publish_create_new(&parent_fd, &temp_name, file_name, &temp_file)
}
}?;
Ok(())
})();
published.map_err(|mut error| {
if let NamedWriteError::Producer { cleanup_error, .. }
| NamedWriteError::BeforePublication { cleanup_error, .. } = &mut error
{
*cleanup_error = remove_owned_temp(&parent_fd, &temp_name, &temp_file).err();
}
error
})?;
let value = value.expect("publication requires successful production");
before(FileCommitStep::FinalParentSync, parent)
.map_err(|source| NamedWriteError::AfterPublication { source })?;
unix_fs::fsync(parent_fd).map_err(|error| NamedWriteError::AfterPublication {
source: errno_to_io(error),
})?;
Ok(value)
}
pub(super) fn native_permissions(permissions: u32) -> io::Result<Mode> {
validate_permissions(permissions)?;
#[cfg(target_vendor = "apple")]
let permissions = u16::try_from(permissions).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "invalid file permission bits")
})?;
Ok(Mode::from_raw_mode(permissions))
}
fn retain_read_only(
parent_fd: &impl AsFd,
parent_path: Option<&Path>,
name: &OsStr,
original: &fs::File,
) -> io::Result<fs::File> {
let read_only = fs::File::from(
unix_fs::openat(
parent_fd,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(errno_to_io)?,
);
let held = unix_fs::fstat(original).map_err(errno_to_io)?;
let reopened = unix_fs::fstat(&read_only).map_err(errno_to_io)?;
if !same_identity(&held, &reopened) {
return Err(staging_identity_error());
}
verify_staging(parent_fd, parent_path, name, &read_only)?;
Ok(read_only)
}
fn same_identity(left: &unix_fs::Stat, right: &unix_fs::Stat) -> bool {
left.st_dev == right.st_dev
&& left.st_ino == right.st_ino
&& unix_fs::FileType::from_raw_mode(left.st_mode)
== unix_fs::FileType::from_raw_mode(right.st_mode)
}
fn staging_identity_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
"staging or parent identity changed",
)
}
pub(super) fn verify_staging(
parent_fd: &impl AsFd,
parent: Option<&Path>,
name: &OsStr,
file: &fs::File,
) -> io::Result<()> {
if let Some(parent) = parent {
let held_parent = unix_fs::fstat(parent_fd).map_err(errno_to_io)?;
let named_parent = unix_fs::statat(unix_fs::CWD, parent, AtFlags::SYMLINK_NOFOLLOW)
.map_err(errno_to_io)?;
if !same_identity(&held_parent, &named_parent) {
return Err(staging_identity_error());
}
}
let held = unix_fs::fstat(file).map_err(errno_to_io)?;
let named =
unix_fs::statat(parent_fd, name, AtFlags::SYMLINK_NOFOLLOW).map_err(errno_to_io)?;
if !same_identity(&held, &named)
|| unix_fs::FileType::from_raw_mode(held.st_mode) != unix_fs::FileType::RegularFile
|| held.st_nlink != 1
|| named.st_nlink != 1
{
return Err(staging_identity_error());
}
Ok(())
}
fn remove_owned_temp(parent_fd: &impl AsFd, name: &OsStr, file: &fs::File) -> io::Result<()> {
let named = match unix_fs::statat(parent_fd, name, AtFlags::SYMLINK_NOFOLLOW) {
Ok(named) => named,
Err(rustix::io::Errno::NOENT) => return Ok(()),
Err(error) => return Err(errno_to_io(error)),
};
let held = unix_fs::fstat(file).map_err(errno_to_io)?;
if !same_identity(&held, &named) {
return Err(staging_identity_error());
}
unix_fs::unlinkat(parent_fd, name, AtFlags::empty()).map_err(errno_to_io)
}
pub(super) fn publish_create_new<E>(
parent_fd: &impl AsFd,
temp_name: &OsStr,
file_name: &OsStr,
temp_file: &fs::File,
) -> Result<(), NamedWriteError<E>> {
let renamed = unix_fs::renameat_with(
parent_fd,
temp_name,
parent_fd,
file_name,
RenameFlags::NOREPLACE,
);
finish_create_new_publication(parent_fd, temp_name, file_name, renamed, || {
remove_owned_temp(parent_fd, temp_name, temp_file)
})
}
pub(super) fn finish_create_new_publication<E>(
parent_fd: &impl AsFd,
temp_name: &OsStr,
file_name: &OsStr,
renamed: rustix::io::Result<()>,
cleanup: impl FnOnce() -> io::Result<()>,
) -> Result<(), NamedWriteError<E>> {
match renamed {
Ok(()) => Ok(()),
Err(
rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP,
) => {
unix_fs::linkat(parent_fd, temp_name, parent_fd, file_name, AtFlags::empty())
.map_err(|error| NamedWriteError::before(errno_to_io(error)))?;
cleanup().map_err(|source| NamedWriteError::AfterPublication { source })
}
Err(error) => Err(NamedWriteError::before(errno_to_io(error))),
}
}
#[cfg(test)]
pub(super) fn publish_create_new_after_error(
parent: &Path,
temp_name: &OsStr,
file_name: &OsStr,
error: rustix::io::Errno,
) -> Result<(), NamedWriteError<io::Error>> {
let parent_fd = open_directory(parent).map_err(NamedWriteError::before)?;
finish_create_new_publication(&parent_fd, temp_name, file_name, Err(error), || {
remove_temp(&parent_fd, temp_name)
})
}
pub(super) fn validate_target_path(path: &Path) -> io::Result<()> {
let bytes = path.as_os_str().as_encoded_bytes();
if bytes.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"durable write target contains NUL",
));
}
if bytes.ends_with(b"/") || bytes.ends_with(b"/.") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"durable write target requires a directory",
));
}
Ok(())
}
fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
validate_target_path(path)?;
let file_name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("durable write target has no file name: {}", path.display()),
)
})?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
Ok((parent, file_name))
}
pub(super) fn validate_permissions(permissions: u32) -> io::Result<()> {
if permissions & !0o777 != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid file permission bits",
));
}
Ok(())
}
pub(super) fn validate_file_name(name: &OsStr) -> io::Result<()> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name
.as_encoded_bytes()
.iter()
.any(|byte| matches!(byte, b'/' | 0))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"expected one filename component",
));
}
Ok(())
}
pub(super) fn prepare_private_parents(parent: &Path) -> io::Result<()> {
let mut component_path = PathBuf::new();
for component in parent.components() {
if component == Component::ParentDir {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"private parent path may not contain '..'",
));
}
component_path.push(component.as_os_str());
match fs::symlink_metadata(&component_path) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!(
"output parent is not a directory: {}",
component_path.display()
),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
create_parent_hierarchy(parent, &mut |_, _| Ok(()), true)
}
fn create_parent_hierarchy(
parent: &Path,
before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
private: bool,
) -> io::Result<()> {
let mut missing = Vec::new();
let mut current = parent;
loop {
match fs::symlink_metadata(current) {
Ok(metadata) if metadata.is_dir() => break,
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!("output parent is not a directory: {}", current.display()),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
missing.push(current.to_path_buf());
current = current
.parent()
.filter(|ancestor| !ancestor.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
}
Err(error) => return Err(error),
}
}
for directory in missing.into_iter().rev() {
before(FileCommitStep::ParentDirectoryCreate, &directory)?;
let creation = if private {
fs::DirBuilder::new().mode(0o700).create(&directory)
} else {
fs::create_dir(&directory)
};
match creation {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
if !fs::symlink_metadata(&directory)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!("output parent is not a directory: {}", directory.display()),
));
}
}
Err(error) => return Err(error),
}
sync_created_directory(&directory, before)?;
}
Ok(())
}
fn sync_created_directory(
directory: &Path,
before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
) -> io::Result<()> {
before(FileCommitStep::CreatedDirectorySync, directory)?;
let directory_fd = open_directory(directory)?;
unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
let owner = directory
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
let owner_fd = open_directory(owner)?;
unix_fs::fsync(&owner_fd).map_err(errno_to_io)
}
fn create_sibling_temp(
parent_fd: &impl AsFd,
parent: &Path,
file_name: &OsStr,
permissions: Mode,
before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
) -> io::Result<(OsString, PathBuf, fs::File)> {
for _ in 0..TEMP_ATTEMPTS {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = format!(".ic-host-tmp-{}-{sequence}", std::process::id());
if file_name
.as_encoded_bytes()
.eq_ignore_ascii_case(name.as_bytes())
{
continue;
}
let temp_name = OsString::from(name);
let temp_path = parent.join(&temp_name);
before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
match unix_fs::openat(
parent_fd,
&temp_name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
permissions,
) {
Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
Err(error) if error == rustix::io::Errno::EXIST => {}
Err(error) => return Err(errno_to_io(error)),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"could not allocate a unique sibling temporary file for {}",
parent.join(file_name).display()
),
))
}
fn open_directory(path: &Path) -> io::Result<OwnedFd> {
unix_fs::open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(errno_to_io)
}
#[cfg(test)]
fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) -> io::Result<()> {
unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty()).map_err(errno_to_io)
}
fn errno_to_io(error: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(error.raw_os_error())
}
}