use std::fs::{self, File, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallMode {
Replace,
NewOnly,
}
pub(crate) struct AtomicInstall<'a> {
pub bytes: &'a [u8],
pub mode: InstallMode,
pub permissions: Option<fs::Permissions>,
pub unix_mode: Option<u32>,
}
impl<'a> AtomicInstall<'a> {
pub(crate) fn replacing(bytes: &'a [u8]) -> Self {
Self {
bytes,
mode: InstallMode::Replace,
permissions: None,
unix_mode: None,
}
}
pub(crate) fn with_permissions(mut self, permissions: Option<fs::Permissions>) -> Self {
self.permissions = permissions;
self
}
pub(crate) fn with_unix_mode(mut self, unix_mode: Option<u32>) -> Self {
self.unix_mode = unix_mode;
self
}
pub(crate) fn new_only(mut self) -> Self {
self.mode = InstallMode::NewOnly;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AtomicStep {
NoParent,
NonUtf8Name,
Temp,
TempExhausted,
Write,
PreservePermissions,
#[cfg(unix)]
SetPermissions,
Sync,
Install,
SyncParent,
}
#[derive(Debug)]
pub(crate) struct AtomicError {
pub step: AtomicStep,
pub path: PathBuf,
pub source: Option<std::io::Error>,
}
impl AtomicError {
fn at(step: AtomicStep, path: &Path, source: std::io::Error) -> Self {
Self {
step,
path: path.to_path_buf(),
source: Some(source),
}
}
pub(crate) fn target_exists(&self) -> bool {
self.step == AtomicStep::Install
&& self
.source
.as_ref()
.is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists)
}
pub(crate) fn commit_uncertain(&self) -> bool {
self.step == AtomicStep::SyncParent
}
}
impl std::fmt::Display for AtomicError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path = self.path.display();
let phrase = match self.step {
AtomicStep::NoParent => {
return write!(formatter, "has no parent directory for `{path}`");
}
AtomicStep::NonUtf8Name => {
return write!(formatter, "path is not valid UTF-8: `{path}`");
}
AtomicStep::TempExhausted => {
return write!(formatter, "could not allocate temporary file in `{path}`");
}
AtomicStep::Temp => "temporary file in",
AtomicStep::Write => "write",
AtomicStep::PreservePermissions => "preserve permissions",
#[cfg(unix)]
AtomicStep::SetPermissions => "set permissions on",
AtomicStep::Sync => "fsync",
AtomicStep::Install => "install",
AtomicStep::SyncParent => "fsync parent directory",
};
write!(formatter, "{phrase} `{path}`")?;
match &self.source {
Some(source) => write!(formatter, ": {source}"),
None => Ok(()),
}
}
}
const MAX_NAME_BYTES: usize = 255;
fn temp_file_name(file_name: &str, pid: u32, attempt: u32) -> String {
let suffix = format!(".afdata.{pid}.{attempt}.tmp");
let budget = MAX_NAME_BYTES.saturating_sub(suffix.len() + 1);
let mut stem = file_name;
if stem.len() > budget {
let mut cut = budget;
while cut > 0 && !stem.is_char_boundary(cut) {
cut -= 1;
}
stem = &stem[..cut];
}
format!(".{stem}{suffix}")
}
fn parent_and_name(path: &Path) -> Result<(&Path, String), AtomicError> {
let parent = match path.parent() {
Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
Some(parent) => parent,
None => {
return Err(AtomicError {
step: AtomicStep::NoParent,
path: path.to_path_buf(),
source: None,
});
}
};
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| AtomicError {
step: AtomicStep::NonUtf8Name,
path: path.to_path_buf(),
source: None,
})?
.to_string();
Ok((parent, file_name))
}
fn allocate_private_temp(parent: &Path, file_name: &str) -> Result<(PathBuf, File), AtomicError> {
let pid = std::process::id();
for attempt in 0..32_u32 {
let candidate = parent.join(temp_file_name(file_name, pid, attempt));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
match options.open(&candidate) {
Ok(file) => return Ok((candidate, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(AtomicError::at(AtomicStep::Temp, parent, error)),
}
}
Err(AtomicError {
step: AtomicStep::TempExhausted,
path: parent.to_path_buf(),
source: None,
})
}
#[cfg(unix)]
fn sync_parent(parent: &Path) -> Result<(), AtomicError> {
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| AtomicError::at(AtomicStep::SyncParent, parent, error))
}
#[cfg(not(unix))]
fn sync_parent(_parent: &Path) -> Result<(), AtomicError> {
Ok(())
}
fn write_temp(
mut temp_file: File,
temp_path: &Path,
target: &Path,
request: &AtomicInstall<'_>,
) -> Result<(), AtomicError> {
temp_file
.write_all(request.bytes)
.map_err(|error| AtomicError::at(AtomicStep::Write, target, error))?;
if let Some(permissions) = request.permissions.clone() {
temp_file
.set_permissions(permissions)
.map_err(|error| AtomicError::at(AtomicStep::PreservePermissions, target, error))?;
}
#[cfg(unix)]
if let Some(unix_mode) = request.unix_mode {
use std::os::unix::fs::PermissionsExt as _;
temp_file
.set_permissions(fs::Permissions::from_mode(unix_mode))
.map_err(|error| AtomicError::at(AtomicStep::SetPermissions, target, error))?;
}
temp_file
.sync_all()
.map_err(|error| AtomicError::at(AtomicStep::Sync, temp_path, error))
}
pub(crate) fn install(path: &Path, request: AtomicInstall<'_>) -> Result<(), AtomicError> {
let (parent, file_name) = parent_and_name(path)?;
let (temp_path, temp_file) = allocate_private_temp(parent, &file_name)?;
let result = (|| -> Result<(), AtomicError> {
write_temp(temp_file, &temp_path, path, &request)?;
match request.mode {
InstallMode::Replace => {
fs::rename(&temp_path, path)
.map_err(|error| AtomicError::at(AtomicStep::Install, path, error))?;
}
InstallMode::NewOnly => {
fs::hard_link(&temp_path, path)
.map_err(|error| AtomicError::at(AtomicStep::Install, path, error))?;
let _ = fs::remove_file(&temp_path);
}
}
sync_parent(parent)?;
Ok(())
})();
if let Err(error) = &result {
if !error.commit_uncertain() {
let _ = fs::remove_file(&temp_path);
}
}
result
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use super::*;
#[test]
fn temp_name_stays_within_one_path_component() {
let long = "x".repeat(400);
let name = temp_file_name(&long, 1234, 0);
assert!(name.len() <= MAX_NAME_BYTES, "{} bytes", name.len());
assert!(name.starts_with('.'));
assert!(name.ends_with(".tmp"));
}
#[test]
fn temp_name_truncates_on_a_character_boundary() {
let long = "é".repeat(300);
let name = temp_file_name(&long, 1234, 0);
assert!(name.is_char_boundary(name.len()));
assert!(name.len() <= MAX_NAME_BYTES);
}
#[test]
fn bare_relative_paths_use_the_current_directory() {
let (parent, file_name) = parent_and_name(Path::new("config.json")).unwrap();
assert_eq!(parent, Path::new("."));
assert_eq!(file_name, "config.json");
}
#[test]
fn replace_installs_new_bytes_and_leaves_no_temporary_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, b"old").unwrap();
install(&path, AtomicInstall::replacing(b"new")).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
let strays: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "temporary files left behind");
}
#[test]
fn new_only_refuses_an_existing_target_without_touching_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, b"original").unwrap();
let error = install(&path, AtomicInstall::replacing(b"new").new_only()).unwrap_err();
assert!(error.target_exists());
assert_eq!(fs::read(&path).unwrap(), b"original");
}
#[cfg(unix)]
#[test]
fn replace_swaps_a_symlink_rather_than_following_it() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let outside = dir.path().join("outside.txt");
fs::write(&outside, b"untouched").unwrap();
let link = dir.path().join("link.txt");
symlink(&outside, &link).unwrap();
install(&link, AtomicInstall::replacing(b"new")).unwrap();
assert_eq!(fs::read(&outside).unwrap(), b"untouched");
assert!(
!fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read(&link).unwrap(), b"new");
}
#[cfg(unix)]
#[test]
fn explicit_unix_mode_wins_over_the_private_temporary_mode() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("readable.md");
install(
&path,
AtomicInstall::replacing(b"body").with_unix_mode(Some(0o644)),
)
.unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644);
}
#[test]
fn a_missing_parent_directory_fails_before_any_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent").join("config.json");
let error = install(&path, AtomicInstall::replacing(b"new")).unwrap_err();
assert_eq!(error.step, AtomicStep::Temp);
assert!(!error.commit_uncertain());
assert!(!path.exists());
}
}