Files

241 lines
8.8 KiB
Rust

#[cfg(unix)]
use craft_atomic::{NewFilePermissions, WriteOptions, atomic_write, write_new};
use craft_atomic::{atomic_write_with, or_plain_fsync, write_new_with};
use std::cell::Cell;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("craft-atomic-int-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).ok();
Self(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_no_temp(dir: &Path) {
let leftovers: Vec<_> = std::fs::read_dir(dir)
.ok()
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
.unwrap_or_default();
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
}
const ORIGINAL: &[u8] = b"original precious document";
#[test]
fn atomic_write_with_streaming_writer() {
let d = TempDir::new("stream");
let p = d.0.join("doc.dat");
std::fs::write(&p, ORIGINAL).ok();
atomic_write_with(&p, |f| {
f.write_all(b"chunk1 ")?;
f.write_all(b"chunk2")
})
.ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn write_new_with_streaming_writer() {
let d = TempDir::new("write_new_stream");
let p = d.0.join("new.dat");
write_new_with(&p, |f| f.write_all(b"new content")).ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
assert_no_temp(&d.0);
// Second write to existing file must fail with AlreadyExists
let res = write_new_with(&p, |f| f.write_all(b"overwritten?"));
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
}
#[test]
fn streaming_failure_aborts_and_keeps_original() {
let d = TempDir::new("stream_fail");
let p = d.0.join("doc.dat");
std::fs::write(&p, ORIGINAL).ok();
let res = atomic_write_with(&p, |f| {
f.write_all(b"half a file")?;
Err(io::Error::new(io::ErrorKind::StorageFull, "disk full"))
});
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn apple_full_fsync_fallback_behavior() {
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
for (errno, name) in [(45, "ENOTSUP (SMB)"), (25, "ENOTTY")] {
let fsynced = Cell::new(false);
let r = or_plain_fsync(refused(errno), || {
fsynced.set(true);
Ok(())
});
let apple = cfg!(target_vendor = "apple");
assert_eq!(r.is_ok(), apple, "{name}");
assert_eq!(fsynced.get(), apple, "{name}: fsync fallback only runs on Apple");
}
// Unrelated errors (e.g. EIO = 5) must not fallback
let r = or_plain_fsync(refused(5), || panic!("should not run for EIO"));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
}
#[cfg(unix)]
#[test]
fn mode_and_symlink_preservation() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("mode_symlink");
// Permissions
let p = d.0.join("secret.key");
std::fs::write(&p, ORIGINAL).ok();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
atomic_write(&p, b"updated secret").ok();
let mode = std::fs::metadata(&p).map(|m| m.permissions().mode() & 0o777).ok();
assert_eq!(mode, Some(0o600));
// Symlink
let real = d.0.join("real.txt");
let link = d.0.join("link.txt");
std::fs::write(&real, ORIGINAL).ok();
std::os::unix::fs::symlink(&real, &link).ok();
atomic_write(&link, b"through symlink").ok();
assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_eq!(std::fs::read(&real).ok(), Some(b"through symlink".to_vec()));
}
#[cfg(unix)]
#[test]
fn dangling_relative_symlink_stays_a_link() {
let d = TempDir::new("dangling");
std::fs::create_dir(d.0.join("real")).ok();
let link = d.0.join("link.txt");
std::os::unix::fs::symlink("real/doc.txt", &link).ok();
assert!(atomic_write(&link, b"created through the link").is_ok());
assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_eq!(std::fs::read(d.0.join("real/doc.txt")).ok(), Some(b"created through the link".to_vec()));
assert_no_temp(&d.0);
assert_no_temp(&d.0.join("real"));
}
#[cfg(unix)]
#[test]
fn symlink_loop_is_an_error() {
let d = TempDir::new("loop");
std::os::unix::fs::symlink("b", d.0.join("a")).ok();
std::os::unix::fs::symlink("a", d.0.join("b")).ok();
assert!(atomic_write(&d.0.join("a"), b"x").is_err_and(|e| e.to_string().starts_with("cannot save") && e.to_string().contains("symbolic links")));
assert!(write_new(&d.0.join("a"), b"x").is_err());
assert!(std::fs::symlink_metadata(d.0.join("a")).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn read_only_target_is_not_replaced() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("read_only");
let p = d.0.join("locked.txt");
std::fs::write(&p, ORIGINAL).ok();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).ok();
// Root may write the file anyway; the check only means something for an ordinary user.
if std::fs::OpenOptions::new().write(true).open(&p).is_ok() {
return;
}
let msg = atomic_write(&p, b"new").err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("read-only"), "{msg}");
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn private_staging_preserves_existing_modes_after_callback() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private_staging");
std::fs::set_permissions(&d.0, Permissions::from_mode(0o755))?;
for mode in [0o600, 0o640, 0o664, 0o200] {
let p = d.0.join(format!("doc-{mode:o}.craft"));
std::fs::write(&p, ORIGINAL)?;
std::fs::set_permissions(&p, Permissions::from_mode(mode))?;
atomic_write_with(&p, |f| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600, "stage must be private before the callback, even for a shared destination");
// Settings writers restrict the stage themselves; this must not change the
// destination mode that atomic replacement promises to preserve.
f.set_permissions(Permissions::from_mode(0o600))?;
f.write_all(b"new private payload")?;
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600, "payload must stay private while writing");
Ok(())
})?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, mode);
std::fs::set_permissions(&p, Permissions::from_mode(0o600))?;
assert_eq!(std::fs::read(&p)?, b"new private payload");
}
assert_no_temp(&d.0);
Ok(())
}
#[cfg(unix)]
#[test]
fn explicit_private_files_keep_callback_permissions() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private_new");
let options = WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private);
for exclusive in [false, true] {
let p = d.0.join(format!("new-{exclusive}.craft"));
let fill = |f: &mut std::fs::File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600);
f.write_all(b"new payload")?;
// A new file has no existing mode to restore, so the callback may select it.
f.set_permissions(Permissions::from_mode(0o640))
};
if exclusive {
options.write_new_with(&p, fill)?;
} else {
options.atomic_write_with(&p, fill)?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o640);
assert_eq!(std::fs::read(&p)?, b"new payload");
}
for exclusive in [false, true] {
let p = d.0.join(format!("default-{exclusive}.craft"));
if exclusive {
options.write_new(&p, b"explicitly private")?;
} else {
options.atomic_write(&p, b"explicitly private")?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o600);
}
assert_no_temp(&d.0);
Ok(())
}