use std::path::{Path, PathBuf};
use crate::error::{Result, SeerError};
pub(crate) fn write_atomic_owner_only(path: &Path, content: &str, tmp_ext: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let tmp_path = unique_tmp_path(path, tmp_ext);
std::fs::write(&tmp_path, content).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
SeerError::ConfigError(e.to_string())
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
SeerError::ConfigError(e.to_string())
})?;
Ok(())
}
pub(crate) fn load_or_back_up<T: Default>(
path: &Path,
what: &str,
parse: impl FnOnce(&str) -> std::result::Result<T, String>,
) -> T {
let failure = match std::fs::read_to_string(path) {
Ok(content) => match parse(&content) {
Ok(value) => return value,
Err(e) => e,
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return T::default(),
Err(e) => e.to_string(),
};
let backup = backup_path(path);
match std::fs::rename(path, &backup) {
Ok(()) => tracing::warn!(
path = %path.display(),
backup = %backup.display(),
error = %failure,
"{what} file unreadable; moved to backup",
),
Err(rename_err) => tracing::error!(
path = %path.display(),
error = %rename_err,
"failed to back up unreadable {what}",
),
}
T::default()
}
fn backup_path(path: &Path) -> PathBuf {
let first = path.with_extension("corrupt");
if !first.exists() {
return first;
}
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let mut candidate = path.with_extension(format!("corrupt.{stamp}"));
let mut n = 1u32;
while candidate.exists() {
candidate = path.with_extension(format!("corrupt.{stamp}.{n}"));
n += 1;
}
candidate
}
fn unique_tmp_path(path: &Path, ext: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = SAVE_COUNTER.fetch_add(1, Ordering::Relaxed);
path.with_extension(format!("{}.{}.{}.tmp", ext, std::process::id(), seq))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unreadable_store_is_backed_up_and_never_clobbers_a_prior_backup() {
let dir = TmpDir::new("backup");
std::fs::create_dir_all(&dir.0).unwrap();
let path = dir.0.join("store.json");
let parse = |c: &str| -> std::result::Result<Vec<u8>, String> {
if c == "ok" {
Ok(vec![1])
} else {
Err("bad".to_string())
}
};
std::fs::write(&path, [0xff, 0xfe, b'x']).unwrap();
assert!(load_or_back_up(&path, "test", parse).is_empty());
assert!(!path.exists());
let first = path.with_extension("corrupt");
assert_eq!(std::fs::read(&first).unwrap(), vec![0xff, 0xfe, b'x']);
std::fs::write(&path, "garbage").unwrap();
assert!(load_or_back_up(&path, "test", parse).is_empty());
assert_eq!(std::fs::read(&first).unwrap(), vec![0xff, 0xfe, b'x']);
let backups = std::fs::read_dir(&dir.0)
.unwrap()
.filter(|e| {
e.as_ref()
.unwrap()
.file_name()
.to_string_lossy()
.contains("corrupt")
})
.count();
assert_eq!(backups, 2);
assert!(load_or_back_up(&dir.0.join("missing.json"), "test", parse).is_empty());
std::fs::write(&path, "ok").unwrap();
assert_eq!(load_or_back_up(&path, "test", parse), vec![1]);
}
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
Self(std::env::temp_dir().join(format!(
"seer-fsutil-{}-{}-{}",
tag,
std::process::id(),
n
)))
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn tmp_paths_are_unique_per_call_for_the_same_target() {
let target = Path::new("/data/store.json");
let first = unique_tmp_path(target, "json");
let second = unique_tmp_path(target, "json");
assert_ne!(first, second);
for tmp in [&first, &second] {
assert_eq!(tmp.parent(), target.parent());
assert!(
tmp.extension().is_some_and(|e| e == "tmp"),
"got: {}",
tmp.display()
);
}
}
#[test]
fn write_replaces_content_atomically_without_leftover_temps() {
let dir = TmpDir::new("replace");
let target = dir.0.join("store.json");
write_atomic_owner_only(&target, "{\"v\":1}", "json").expect("first write");
write_atomic_owner_only(&target, "{\"v\":2}", "json").expect("second write");
assert_eq!(
std::fs::read_to_string(&target).expect("read back"),
"{\"v\":2}"
);
let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
.expect("list dir")
.filter_map(|e| e.ok())
.filter(|e| e.path() != target)
.collect();
assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
}
#[cfg(unix)]
#[test]
fn published_file_and_parent_dir_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = TmpDir::new("perms");
let target = dir.0.join("nested").join("store.toml");
write_atomic_owner_only(&target, "x = 1\n", "toml").expect("write");
let file_mode = std::fs::metadata(&target)
.expect("file meta")
.permissions()
.mode()
& 0o777;
assert_eq!(file_mode, 0o600, "file mode: {file_mode:o}");
let dir_mode = std::fs::metadata(target.parent().unwrap())
.expect("dir meta")
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "dir mode: {dir_mode:o}");
}
}