mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
f64ae3bf3c
A native, pure-Rust app that shows anyone how a piece of software works, with evidence-backed answers, then helps them rebuild it. It ships a desktop app, a CLI and an MCP server, all driven by one engine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.5 KiB
Rust
114 lines
4.5 KiB
Rust
//! File safety: atomic writes (temp file, fsync, rename), optionally private (0600 on Unix).
|
|
|
|
use craft_anything_evidence::ToolError;
|
|
use std::io::Write as _;
|
|
use std::path::Path;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// Write `bytes` to `path` atomically, keeping the existing file's permissions when it exists.
|
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ToolError> {
|
|
write_impl(path, bytes, false)
|
|
}
|
|
|
|
/// Write `bytes` to `path` atomically with owner-only permissions (0600 on Unix). Used for
|
|
/// configuration files that may hold paths or tokens, and for the key fallback file.
|
|
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> Result<(), ToolError> {
|
|
write_impl(path, bytes, true)
|
|
}
|
|
|
|
fn write_impl(path: &Path, bytes: &[u8], private: bool) -> Result<(), ToolError> {
|
|
let dir = match path.parent() {
|
|
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
|
Some(_) => std::path::PathBuf::from("."),
|
|
None => return Err(ToolError::invalid(format!("{} has no parent folder", path.display()))),
|
|
};
|
|
std::fs::create_dir_all(&dir).map_err(|e| ToolError::io(dir.display(), e))?;
|
|
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into());
|
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let tmp = dir.join(format!(".{name}.tmp-{}-{n}", std::process::id()));
|
|
let existing_perms = std::fs::metadata(path).ok().map(|m| m.permissions());
|
|
let result = (|| {
|
|
let mut f = std::fs::File::create(&tmp).map_err(|e| ToolError::io(tmp.display(), e))?;
|
|
if private {
|
|
set_private(&tmp)?;
|
|
} else if let Some(p) = existing_perms {
|
|
std::fs::set_permissions(&tmp, p).map_err(|e| ToolError::io(tmp.display(), e))?;
|
|
}
|
|
f.write_all(bytes).map_err(|e| ToolError::io(tmp.display(), e))?;
|
|
f.sync_all().map_err(|e| ToolError::io(tmp.display(), e))?;
|
|
drop(f);
|
|
std::fs::rename(&tmp, path).map_err(|e| ToolError::io(path.display(), e))
|
|
})();
|
|
if result.is_err() {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_private(path: &Path) -> Result<(), ToolError> {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| ToolError::io(path.display(), e))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_private(_path: &Path) -> Result<(), ToolError> {
|
|
// Windows: files under the user's profile are already private to the user by default ACLs.
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy `from` to `to` only if `to` does not exist yet (the first backup is never overwritten).
|
|
/// Returns true when a copy was made, false when a backup already existed.
|
|
pub fn backup_once(from: &Path, to: &Path) -> Result<bool, ToolError> {
|
|
if to.exists() {
|
|
return Ok(false);
|
|
}
|
|
let bytes = std::fs::read(from).map_err(|e| ToolError::io(from.display(), e))?;
|
|
let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).open(to) {
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
|
|
Err(e) => return Err(ToolError::io(to.display(), e)),
|
|
};
|
|
set_private(to)?;
|
|
f.write_all(&bytes).map_err(|e| ToolError::io(to.display(), e))?;
|
|
f.sync_all().map_err(|e| ToolError::io(to.display(), e))?;
|
|
Ok(true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn atomic_write_replaces_and_private_is_0600() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let p = dir.path().join("sub").join("a.txt");
|
|
write_atomic(&p, b"one").unwrap();
|
|
write_atomic(&p, b"two").unwrap();
|
|
assert_eq!(std::fs::read(&p).unwrap(), b"two");
|
|
let q = dir.path().join("secret");
|
|
write_atomic_private(&q, b"k").unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
assert_eq!(std::fs::metadata(&q).unwrap().permissions().mode() & 0o777, 0o600);
|
|
}
|
|
// no temp files left behind
|
|
assert_eq!(std::fs::read_dir(dir.path().join("sub")).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn backup_is_never_overwritten() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let a = dir.path().join("a");
|
|
let b = dir.path().join("a.bak");
|
|
std::fs::write(&a, "first").unwrap();
|
|
assert!(backup_once(&a, &b).unwrap());
|
|
std::fs::write(&a, "second").unwrap();
|
|
assert!(!backup_once(&a, &b).unwrap());
|
|
assert_eq!(std::fs::read_to_string(&b).unwrap(), "first");
|
|
}
|
|
}
|