use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
#[derive(Debug)]
pub struct IoFault(pub String);
pub fn read_bytes(path: &Path) -> Result<Vec<u8>, IoFault> {
fs::read(path).map_err(|e| IoFault(format!("cannot read {}: {e}", path.display())))
}
pub fn to_text(bytes: &[u8], path: &Path) -> Result<String, IoFault> {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Err(IoFault(format!(
"{} starts with a UTF-8 byte-order mark. incise addresses by content, \
and a BOM sits in front of the first heading -- every address into this \
file would miss. Strip it first: `tail -c +4`.",
path.display()
)));
}
String::from_utf8(bytes.to_vec())
.map_err(|e| IoFault(format!("{} is not valid UTF-8: {e}", path.display())))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(64);
for b in Sha256::digest(bytes) {
out.push_str(&format!("{b:02x}"));
}
out
}
pub fn write_atomic(path: &Path, data: &str, expected: &[u8]) -> Result<(), IoFault> {
let fault = |e: std::io::Error| IoFault(format!("cannot write {}: {e}", path.display()));
let target = match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => fs::canonicalize(path).map_err(fault)?,
Ok(_) => path.to_path_buf(),
Err(e) => return Err(fault(e)),
};
let dir = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let stem = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let temp: PathBuf = dir.join(format!(".{stem}.incise-{}.tmp", std::process::id()));
let outcome = (|| {
let mut f = fs::File::create(&temp)?;
f.write_all(data.as_bytes())?;
f.sync_all()?;
drop(f);
if let Ok(meta) = fs::metadata(&target) {
let _ = fs::set_permissions(&temp, meta.permissions());
}
if fs::read(&target)? != expected {
return Err(std::io::Error::other(
"the file changed after incise read it; re-read it and retry",
));
}
fs::rename(&temp, &target)
})();
if outcome.is_err() {
let _ = fs::remove_file(&temp);
}
outcome.map_err(fault)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("incise-io-{}-{n}-{name}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn the_hash_is_the_one_everything_else_agrees_with() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_bom_is_refused_by_name() {
let err = to_text("\u{feff}# Title\n".as_bytes(), Path::new("x.md"))
.err()
.unwrap();
assert!(err.0.contains("byte-order mark"), "{}", err.0);
}
#[test]
fn crlf_and_a_missing_final_newline_both_survive_to_text() {
let raw = b"# A\r\n\r\ntext";
assert_eq!(
to_text(raw, Path::new("x.md")).ok().unwrap(),
"# A\r\n\r\ntext"
);
}
#[test]
fn a_concurrent_change_is_not_overwritten() {
let dir = scratch("stale");
let path = dir.join("note.md");
fs::write(&path, "original\n").unwrap();
fs::write(&path, "external\n").unwrap();
let err =
write_atomic(&path, "incise\n", b"original\n").expect_err("a stale write must refuse");
assert!(err.0.contains("changed after incise read it"), "{}", err.0);
assert_eq!(fs::read_to_string(&path).unwrap(), "external\n");
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_symlink_stays_a_symlink_and_its_target_changes() {
use std::os::unix::fs::symlink;
let dir = scratch("symlink");
let target = dir.join("target.md");
let link = dir.join("link.md");
fs::write(&target, "before\n").unwrap();
symlink(&target, &link).unwrap();
write_atomic(&link, "after\n", b"before\n").unwrap();
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(fs::read_to_string(&target).unwrap(), "after\n");
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn replacement_preserves_the_original_mode() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let dir = scratch("mode");
let path = dir.join("note.md");
fs::write(&path, "before\n").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
write_atomic(&path, "after\n", b"before\n").unwrap();
assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o640);
fs::remove_dir_all(dir).unwrap();
}
}