use std::io::{self, Write};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::fs_utf8::{Dir, File, OpenOptions, Permissions};
use metrics::counter;
use tracing::{debug, trace};
pub(super) const TEMP_FILE_ATTEMPTS: u32 = 16;
pub(super) fn write_and_swap(
directory: &Dir,
temp_path: &Utf8Path,
path: &Utf8Path,
expected: Option<&str>,
contents: &str,
permissions: &Permissions,
mut file: File,
) -> io::Result<bool> {
file.write_all(contents.as_bytes())?;
file.flush()?;
debug!(bytes = contents.len(), "temporary file written");
file.sync_all()?;
debug!("temporary file synced");
drop(file);
swap_into_place(directory, temp_path, path, expected, permissions)
}
fn swap_into_place(
directory: &Dir,
temp_path: &Utf8Path,
path: &Utf8Path,
expected: Option<&str>,
permissions: &Permissions,
) -> io::Result<bool> {
directory
.set_permissions(temp_path, permissions.clone())
.inspect(|()| debug!("target mode applied"))?;
if !holds_expected(directory, path, expected)? {
debug!("target changed since it was read; leaving it alone");
return Ok(false);
}
let prepared = prepare_destination(directory, path, permissions)?;
#[cfg(test)]
competing_writer_seam::run(directory, path);
if !holds_expected(directory, path, expected)? {
debug!("target changed before the swap; leaving it alone");
undo_preparation(directory, path, prepared);
return Ok(false);
}
if let Err(error) = rename_over_target(directory, temp_path, path) {
undo_preparation(directory, path, prepared);
return Err(error);
}
debug!("target replaced");
Ok(true)
}
fn undo_preparation(directory: &Dir, path: &Utf8Path, prepared: Option<Permissions>) {
if let Some(original) = prepared {
restore_destination(directory, path, &original);
}
}
fn holds_expected(directory: &Dir, path: &Utf8Path, expected: Option<&str>) -> io::Result<bool> {
let Some(expected) = expected else {
return Ok(true);
};
if directory.read_to_string(path)? == expected {
trace!("target still holds the text it was read as");
Ok(true)
} else {
Ok(false)
}
}
#[cfg(windows)]
fn prepare_destination(
directory: &Dir,
path: &Utf8Path,
permissions: &Permissions,
) -> io::Result<Option<Permissions>> {
if !permissions.readonly() {
return Ok(None);
}
let mut writable = permissions.clone();
writable.set_readonly(false);
directory.set_permissions(path, writable)?;
debug!("destination read-only attribute cleared");
Ok(Some(permissions.clone()))
}
#[cfg(not(windows))]
#[expect(
clippy::unnecessary_wraps,
reason = "the signature must match the Windows preparation, which can fail"
)]
fn prepare_destination(
_directory: &Dir,
_path: &Utf8Path,
_permissions: &Permissions,
) -> io::Result<Option<Permissions>> {
Ok(None)
}
#[cfg(windows)]
fn restore_destination(directory: &Dir, path: &Utf8Path, original: &Permissions) {
if let Err(error) = directory.set_permissions(path, original.clone()) {
debug!(
error_category = ?error.kind(),
"destination mode restore failed"
);
}
}
#[cfg(not(windows))]
fn restore_destination(_directory: &Dir, _path: &Utf8Path, _original: &Permissions) {}
fn rename_over_target(directory: &Dir, temp_path: &Utf8Path, path: &Utf8Path) -> io::Result<()> {
#[cfg(test)]
if rename_failure_seam::take() {
return Err(io::Error::other("the rename failure seam is armed"));
}
directory.rename(temp_path, directory, path)
}
pub(super) fn remove_temporary_file(directory: &Dir, temp_path: &Utf8Path) -> io::Result<()> {
#[cfg(test)]
if cleanup_failure_seam::take() {
return Err(io::Error::other("the cleanup failure seam is armed"));
}
#[cfg(windows)]
{
if let Ok(metadata) = directory.metadata(temp_path) {
let mut permissions = metadata.permissions();
if permissions.readonly() {
permissions.set_readonly(false);
if let Err(error) = directory.set_permissions(temp_path, permissions) {
debug!(
error_category = ?error.kind(),
"temporary file mode could not be cleared"
);
}
}
}
}
directory.remove_file(temp_path)
}
pub(super) fn create_temporary_file(
directory: &Dir,
path: &Utf8Path,
) -> io::Result<(Utf8PathBuf, File)> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
for attempt in 0..TEMP_FILE_ATTEMPTS {
let temp_path = temporary_path(path, attempt);
match directory.open_with(&temp_path, &options) {
Ok(file) => {
debug!(attempt, "temporary file created");
return Ok((temp_path, file));
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
trace!(
attempt,
reason = "already_exists",
"temporary name rejected"
);
counter!("mdtablefix_io_temporary_name_collisions_total").increment(1);
}
Err(error) => return Err(error),
}
}
counter!("mdtablefix_io_temporary_name_exhausted_total").increment(1);
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("no free temporary file name beside {path} after {TEMP_FILE_ATTEMPTS} attempts"),
))
}
pub(super) fn temporary_path(path: &Utf8Path, attempt: u32) -> Utf8PathBuf {
let name = format!(
"{}.mdtablefix-{}-{attempt}.tmp",
path.file_name().unwrap_or_default(),
std::process::id()
);
path.with_file_name(name)
}
#[cfg(test)]
pub(crate) mod rename_failure_seam {
use std::cell::Cell;
thread_local! {
static ARMED: Cell<bool> = const { Cell::new(false) };
}
pub(crate) fn arm() -> Armed {
ARMED.with(|armed| armed.set(true));
Armed
}
pub(crate) struct Armed;
impl Drop for Armed {
fn drop(&mut self) { ARMED.with(|armed| armed.set(false)); }
}
pub(crate) fn take() -> bool { ARMED.with(|armed| armed.replace(false)) }
}
#[cfg(test)]
pub(crate) mod cleanup_failure_seam {
use std::cell::Cell;
thread_local! {
static ARMED: Cell<bool> = const { Cell::new(false) };
}
pub(crate) fn arm() -> Armed {
ARMED.with(|armed| armed.set(true));
Armed
}
pub(crate) struct Armed;
impl Drop for Armed {
fn drop(&mut self) { ARMED.with(|armed| armed.set(false)); }
}
pub(crate) fn take() -> bool { ARMED.with(|armed| armed.replace(false)) }
}
#[cfg(test)]
pub(crate) mod competing_writer_seam {
use std::cell::Cell;
use super::{Dir, Utf8Path};
type Intrusion = Box<dyn FnOnce(&Dir, &Utf8Path)>;
thread_local! {
static ARMED: Cell<Option<Intrusion>> = const { Cell::new(None) };
}
pub(crate) fn arm(intrude: impl FnOnce(&Dir, &Utf8Path) + 'static) -> Armed {
ARMED.with(|armed| armed.set(Some(Box::new(intrude))));
Armed
}
pub(crate) struct Armed;
impl Drop for Armed {
fn drop(&mut self) { ARMED.with(|armed| armed.set(None)); }
}
pub(crate) fn run(directory: &Dir, path: &Utf8Path) {
if let Some(intrude) = ARMED.with(Cell::take) {
intrude(directory, path);
}
}
}