use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Clone, Copy)]
enum AtomicWriteKind {
Normal,
Secret,
}
impl AtomicWriteKind {
fn open_tmp(self, tmp: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
if matches!(self, Self::Secret) {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(tmp)
}
fn enforce_before_write(self, file: &File) -> io::Result<()> {
match self {
Self::Normal => Ok(()),
Self::Secret => enforce_secret_permissions_before_write(file),
}
}
}
#[cfg(unix)]
fn enforce_secret_permissions_before_write(file: &File) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
let mode = file.metadata()?.permissions().mode() & 0o777;
if mode != 0o600 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("secret temp file permissions are {mode:o}, expected 600"),
));
}
Ok(())
}
#[cfg(not(unix))]
fn enforce_secret_permissions_before_write(_file: &File) -> io::Result<()> {
Ok(())
}
static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
const ENOSPC: i32 = 28;
const ENOTEMPTY_LINUX: i32 = 39;
const ENOTEMPTY_MACOS: i32 = 66;
const ENOTEMPTY_WINDOWS: i32 = 145;
const EACCES: i32 = 13;
const ENOENT: i32 = 2;
const EROFS: i32 = 30;
const EXDEV: i32 = 18;
pub fn is_out_of_space(err: &io::Error) -> bool {
if err.raw_os_error() == Some(ENOSPC) {
return true;
}
if err.kind() == io::ErrorKind::StorageFull {
return true;
}
if err.kind() == io::ErrorKind::WriteZero {
return true;
}
false
}
pub fn is_directory_not_empty(err: &io::Error) -> bool {
if err.kind() == io::ErrorKind::DirectoryNotEmpty {
return true;
}
matches!(
err.raw_os_error(),
Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
)
}
pub fn is_permission_denied(err: &io::Error) -> bool {
if err.kind() == io::ErrorKind::PermissionDenied {
return true;
}
err.raw_os_error() == Some(EACCES)
}
pub fn is_not_found(err: &io::Error) -> bool {
if err.kind() == io::ErrorKind::NotFound {
return true;
}
err.raw_os_error() == Some(ENOENT)
}
pub fn is_read_only_filesystem(err: &io::Error) -> bool {
if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
return true;
}
err.raw_os_error() == Some(EROFS)
}
pub fn is_cross_device_link(err: &io::Error) -> bool {
if err.kind() == io::ErrorKind::CrossesDevices {
return true;
}
err.raw_os_error() == Some(EXDEV)
}
pub fn temp_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("heddle-tmp");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
}
#[cfg(windows)]
pub fn sync_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(not(windows))]
pub fn sync_directory(path: &Path) -> io::Result<()> {
let dir = OpenOptions::new().read(true).open(path)?;
dir.sync_all()
}
fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
enrich_fs_error(path, "writing", err)
}
pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
if is_out_of_space(&err) {
let msg = format!(
"out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
path.display()
);
return io::Error::new(
io::ErrorKind::StorageFull,
EnrichedFsError { msg, source: err },
);
}
if is_directory_not_empty(&err) {
let msg = format!(
"could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
path.display()
);
return io::Error::new(
io::ErrorKind::DirectoryNotEmpty,
EnrichedFsError { msg, source: err },
);
}
if is_read_only_filesystem(&err) {
let msg = format!(
"filesystem is read-only — `{}` cannot be modified",
path.display()
);
return io::Error::new(
io::ErrorKind::ReadOnlyFilesystem,
EnrichedFsError { msg, source: err },
);
}
if is_permission_denied(&err) {
let msg = format!(
"permission denied {op} `{}` — check filesystem permissions",
path.display()
);
return io::Error::new(
io::ErrorKind::PermissionDenied,
EnrichedFsError { msg, source: err },
);
}
if is_not_found(&err) {
let msg = format!("could not find `{}` for {op}", path.display());
return io::Error::new(
io::ErrorKind::NotFound,
EnrichedFsError { msg, source: err },
);
}
if is_cross_device_link(&err) {
let msg = format!(
"cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
path.display()
);
return io::Error::new(
io::ErrorKind::CrossesDevices,
EnrichedFsError { msg, source: err },
);
}
err
}
pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
if is_cross_device_link(&err) {
let msg = format!(
"cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
src.display(),
dst.display()
);
return io::Error::new(
io::ErrorKind::CrossesDevices,
EnrichedFsError { msg, source: err },
);
}
enrich_fs_error(dst, "renaming", err)
}
#[derive(Debug)]
struct EnrichedFsError {
msg: String,
source: io::Error,
}
impl std::fmt::Display for EnrichedFsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for EnrichedFsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
fn write_file_atomic_impl(
path: &Path,
bytes: &[u8],
kind: AtomicWriteKind,
before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
let tmp = temp_path(path);
let inner = (|| -> io::Result<()> {
let mut file = kind.open_tmp(&tmp)?;
kind.enforce_before_write(&file)?;
before_write(&file, &tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
})();
if let Err(err) = inner {
let _ = fs::remove_file(&tmp);
return Err(enrich_write_error(path, err));
}
fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
}
pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
}
pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
}
#[cfg(test)]
mod tests {
use super::*;
fn enospc_io_error() -> io::Error {
io::Error::from_raw_os_error(ENOSPC)
}
#[test]
fn is_out_of_space_detects_enospc_raw() {
assert!(is_out_of_space(&enospc_io_error()));
}
#[test]
fn is_out_of_space_detects_storage_full_kind() {
let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
assert!(is_out_of_space(&err));
}
#[test]
fn is_out_of_space_detects_write_zero() {
let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
assert!(is_out_of_space(&err));
}
#[test]
fn is_out_of_space_rejects_unrelated_errors() {
assert!(!is_out_of_space(&io::Error::new(
io::ErrorKind::NotFound,
"missing"
)));
assert!(!is_out_of_space(&io::Error::new(
io::ErrorKind::PermissionDenied,
"nope"
)));
assert!(!is_out_of_space(&io::Error::other("generic")));
}
#[test]
fn is_directory_not_empty_detects_kind() {
let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
assert!(is_directory_not_empty(&err));
}
#[test]
fn is_directory_not_empty_detects_raw_codes() {
for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
assert!(
is_directory_not_empty(&io::Error::from_raw_os_error(code)),
"expected raw OS error {code} to classify as ENOTEMPTY"
);
}
}
#[test]
fn is_directory_not_empty_rejects_unrelated() {
assert!(!is_directory_not_empty(&io::Error::new(
io::ErrorKind::NotFound,
"missing"
)));
assert!(!is_directory_not_empty(&enospc_io_error()));
}
#[test]
fn is_permission_denied_detects_kind_and_raw() {
assert!(is_permission_denied(&io::Error::new(
io::ErrorKind::PermissionDenied,
"nope"
)));
assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
}
#[test]
fn is_not_found_detects_kind_and_raw() {
assert!(is_not_found(&io::Error::new(
io::ErrorKind::NotFound,
"missing"
)));
assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
}
#[test]
fn is_read_only_filesystem_detects_raw() {
assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
EROFS
)));
}
#[test]
fn is_cross_device_link_detects_raw() {
assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
}
#[test]
fn enrich_fs_error_passes_through_unclassified() {
let path = Path::new("/tmp/example");
let original = io::Error::other("weird");
let wrapped = enrich_fs_error(path, "writing", original);
assert_eq!(wrapped.kind(), io::ErrorKind::Other);
assert_eq!(wrapped.to_string(), "weird");
}
#[test]
fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
let path = Path::new("/repo/.heddle/state/abc.bin");
let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
let msg = wrapped.to_string();
assert!(
msg.contains("out of disk space"),
"missing failure name: {msg}"
);
assert!(
msg.contains("/repo/.heddle/state/abc.bin"),
"missing path: {msg}"
);
assert!(
msg.contains("free disk space") && msg.contains("re-run"),
"missing recovery hint: {msg}"
);
assert!(
msg.contains("working tree is unchanged"),
"missing reassurance: {msg}"
);
let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
.or_else(|| wrapped.get_ref().and_then(|e| e.source()))
.expect("source preserved");
assert!(src.to_string().to_lowercase().contains("space"));
}
#[test]
fn enrich_fs_error_wraps_enotempty_with_directory_message() {
let path = Path::new("/repo/web");
let wrapped = enrich_fs_error(
path,
"removing",
io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
);
assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
let msg = wrapped.to_string();
assert!(
msg.contains("could not remove directory"),
"missing action: {msg}"
);
assert!(msg.contains("/repo/web"), "missing path: {msg}");
assert!(
msg.contains("heddle-ignored"),
"missing heddle-ignored hint: {msg}"
);
assert!(
msg.contains("leaving in place"),
"missing reassurance: {msg}"
);
let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
let original = src
.downcast_ref::<io::Error>()
.expect("original io::Error preserved");
assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
}
#[test]
fn enrich_fs_error_wraps_eacces_with_op_and_path() {
let path = Path::new("/repo/.heddle/state/index.bin");
let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
let msg = wrapped.to_string();
assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
}
#[test]
fn enrich_fs_error_wraps_enoent_with_op_and_path() {
let path = Path::new("/repo/.heddle");
let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
let msg = wrapped.to_string();
assert!(msg.contains("could not find"), "missing action: {msg}");
assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
assert!(msg.contains("for opening"), "missing op: {msg}");
}
#[test]
fn enrich_fs_error_wraps_erofs_with_path() {
let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
let msg = wrapped.to_string();
assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
assert!(
msg.contains("/mnt/readonly/.heddle/state/index.bin"),
"msg: {msg}"
);
assert!(msg.contains("cannot be modified"), "msg: {msg}");
}
#[test]
fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
let src = Path::new("/tmp-mount/.x.tmp-1234");
let dst = Path::new("/repo/.heddle/state/index.bin");
let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
let msg = wrapped.to_string();
assert!(
msg.contains("cannot rename across filesystems"),
"msg: {msg}"
);
assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
assert!(
msg.contains("/repo/.heddle/state/index.bin"),
"missing dst: {msg}"
);
assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
}
#[test]
fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
let src = Path::new("/tmp/.x.tmp");
let dst = Path::new("/repo/file");
let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
let msg = wrapped.to_string();
assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
assert!(msg.contains("/repo/file"), "missing dst: {msg}");
}
#[test]
fn enrich_write_error_passes_through_non_enospc_unclassified() {
let path = Path::new("/tmp/example");
let original = io::Error::other("weird");
let wrapped = enrich_write_error(path, original);
assert_eq!(wrapped.kind(), io::ErrorKind::Other);
assert_eq!(wrapped.to_string(), "weird");
}
#[test]
fn write_file_atomic_round_trip() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("nested/under/here/file.bin");
write_file_atomic(&target, b"hello").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"hello");
}
#[cfg(unix)]
#[test]
fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("nested/secret.txt");
let mut observed_tmp_mode = None;
write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
let fd_mode = file.metadata()?.permissions().mode() & 0o777;
let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
observed_tmp_mode = Some((fd_mode, path_mode));
Ok(())
})
.unwrap();
assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(final_mode, 0o600);
assert_eq!(fs::read(&target).unwrap(), b"secret");
}
#[test]
fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("secret.txt");
let mut tmp_path = None;
let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
tmp_path = Some(tmp.to_path_buf());
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected permission failure",
))
})
.expect_err("permission failure should propagate");
assert!(is_permission_denied(&err), "unexpected error: {err}");
assert!(!target.exists(), "secret write must not publish target");
let tmp = tmp_path.expect("pre-write hook observed temp path");
assert!(!tmp.exists(), "failed secret write should remove temp file");
}
#[test]
fn sync_directory_succeeds_on_writable_tempdir() {
let dir = tempfile::TempDir::new().unwrap();
sync_directory(dir.path()).expect("sync_directory on writable tempdir");
}
#[test]
fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("oplog/oplog.bin");
let result = write_file_atomic(&target, b"hello");
if let Err(e) = &result {
assert!(
!is_permission_denied(e),
"write_file_atomic surfaced PermissionDenied on a writable \
tempdir (heddle#105): {e}"
);
}
result.expect("write_file_atomic");
}
}