use super::*;
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
use super::supported::{
FileCommitStep, commit_with_hook, publish_create_new_after_error,
read_optional_regular_bytes_bounded_with_hook,
};
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
#[test]
fn durable_lock_reports_wait_for_another_process_and_retains_exclusion() {
use std::{
io::{BufRead, Write},
process::{Command, Stdio},
time::{Duration, Instant},
};
const DRIVER: &str = "CANIC_TEST_PROGRESS_LOCK_DRIVER";
const CHILD_ROOT: &str = "CANIC_TEST_PROGRESS_LOCK_ROOT";
let thread = std::thread::current();
let test_name = thread.name().expect("libtest names each test thread");
if std::env::var_os(DRIVER).is_none() {
let output = Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name])
.env(DRIVER, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"isolated lock test failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
if let Some(root) = std::env::var_os(CHILD_ROOT) {
let _lock = lock_file(&PathBuf::from(root).join("complete-build-reuse.lock")).unwrap();
println!("LOCK_HELD");
io::stdout().flush().unwrap();
let mut line = String::new();
io::stdin().read_line(&mut line).unwrap();
return;
}
let root = temp_root("progress-lock");
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--nocapture"])
.env(CHILD_ROOT, &root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = io::BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert_ne!(
stdout.read_line(&mut line).unwrap(),
0,
"child must hold lock"
);
if line.contains("LOCK_HELD") {
break;
}
}
let path = root.join("complete-build-reuse.lock");
let started = Instant::now();
let mut progress = None;
let lock = lock_file_with_progress(&path, |_, elapsed| {
if progress.is_none() {
progress = Some(elapsed);
child
.stdin
.as_mut()
.unwrap()
.write_all(b"release\n")
.unwrap();
}
Ok(())
})
.unwrap();
assert!(child.wait().unwrap().success());
assert!(progress.unwrap() >= Duration::from_secs(1));
assert!(started.elapsed() >= progress.unwrap());
let contender = fs::File::open(&path).unwrap();
assert_eq!(
rustix::fs::flock(
&contender,
rustix::fs::FlockOperation::NonBlockingLockExclusive
),
Err(rustix::io::Errno::WOULDBLOCK)
);
drop(lock);
rustix::fs::flock(
&contender,
rustix::fs::FlockOperation::NonBlockingLockExclusive,
)
.unwrap();
drop(contender);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn durable_write_creates_parents_and_replaces_complete_contents() {
let root = temp_root("replace");
let path = root.join("reports/nested/state.json");
write_bytes(&path, b"old").expect("write initial contents");
write_bytes(&path, b"new complete contents").expect("replace contents");
assert_eq!(
fs::read(&path).expect("read target"),
b"new complete contents"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn durable_create_new_with_parents_never_replaces_an_existing_file() {
let root = temp_root("create-new-with-parents");
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("nested/report.json");
create_new_bytes_with_parents(&path, b"first complete contents").expect("create output");
let error = create_new_bytes_with_parents(&path, b"replacement")
.expect_err("existing output must reject");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
fs::read(&path).expect("read target"),
b"first complete contents"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(unix)]
#[test]
fn durable_create_new_with_parents_rejects_a_symlinked_parent() {
use std::os::unix::fs::symlink;
let root = temp_root("create-new-symlink-parent");
let outside = temp_root("create-new-symlink-outside");
fs::create_dir_all(&root).expect("create temp root");
fs::create_dir_all(&outside).expect("create outside root");
symlink(&outside, root.join("linked")).expect("create parent symlink");
let error = create_new_bytes_with_parents(&root.join("linked/report.json"), b"contents")
.expect_err("symlinked parent must reject");
assert_eq!(error.kind(), io::ErrorKind::NotADirectory);
assert!(!outside.join("report.json").exists());
fs::remove_dir_all(root).expect("remove temp root");
fs::remove_dir_all(outside).expect("remove outside root");
}
#[test]
fn durable_write_rejects_a_target_without_a_file_name() {
let error = write_bytes(Path::new("/"), b"value").expect_err("directory target must fail");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn bounded_regular_read_rejects_existing_and_growing_oversize_content() {
let root = temp_root("bounded-read-oversize");
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("object");
fs::write(&path, b"12345").expect("write oversized object");
assert!(matches!(
read_optional_regular_bytes_bounded(&path, 4),
Err(BoundedRegularFileReadError::TooLarge)
));
fs::write(&path, b"1234").expect("write initially bounded object");
let growing =
read_optional_regular_bytes_bounded_with_hook(&path, 4, || fs::write(&path, b"12345"));
assert!(matches!(
growing,
Err(BoundedRegularFileReadError::TooLarge)
));
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn bounded_regular_read_preserves_exact_bytes_and_rejects_links() {
let root = temp_root("bounded-read-regular");
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("object");
fs::write(&path, b"1234").expect("write bounded object");
assert_eq!(
read_optional_regular_bytes_bounded(&path, 4).expect("bounded read"),
Some(b"1234".to_vec())
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&path, root.join("linked-object")).expect("create object link");
assert!(matches!(
read_optional_regular_bytes_bounded(&root.join("linked-object"), 4),
Err(BoundedRegularFileReadError::Read(
RegularFileReadError::NotRegular
))
));
}
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn prepublication_failures_preserve_old_complete_bytes_and_remove_staging() {
let steps = [
FileCommitStep::TemporaryFileCreate,
FileCommitStep::TemporaryFileWrite,
FileCommitStep::TemporaryFileSync,
FileCommitStep::Publication,
];
for step in steps {
let root = temp_root(&format!("prepublication-{step:?}"));
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("report.json");
fs::write(&path, b"old complete contents").expect("write old contents");
let mut failed = false;
let error = commit_with_hook(
&path,
b"new complete contents",
FileCommitMode::Replace,
|current, _| {
if current == step && !failed {
failed = true;
return Err(io::Error::other("injected file commit failure"));
}
Ok(())
},
)
.expect_err("injected step must fail");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert_eq!(
fs::read(&path).expect("read preserved target"),
b"old complete contents"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn postpublication_sync_failure_exposes_only_new_complete_bytes() {
let root = temp_root("postpublication");
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("report.json");
fs::write(&path, b"old complete contents").expect("write old contents");
let error = commit_with_hook(
&path,
b"new complete contents",
FileCommitMode::Replace,
|step, _| {
if step == FileCommitStep::FinalParentSync {
return Err(io::Error::other("injected parent sync failure"));
}
Ok(())
},
)
.expect_err("postpublication sync must fail");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert_eq!(
fs::read(&path).expect("read published target"),
b"new complete contents"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn create_new_publication_race_cannot_replace_the_winner() {
let root = temp_root("create-new-race");
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("report.json");
let error = commit_with_hook(
&path,
b"our complete contents",
FileCommitMode::CreateNewWithParents,
|step, _| {
if step == FileCommitStep::Publication {
fs::write(&path, b"raced complete contents")?;
}
Ok(())
},
)
.expect_err("atomic create-new must reject a publication race");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
fs::read(&path).expect("read winning target"),
b"raced complete contents"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn unsupported_no_replace_rename_uses_atomic_link_publication() {
let unsupported = [
("invalid", rustix::io::Errno::INVAL),
("no-syscall", rustix::io::Errno::NOSYS),
("not-supported", rustix::io::Errno::OPNOTSUPP),
];
for (label, error) in unsupported {
let root = temp_root(&format!("create-new-link-{label}"));
fs::create_dir_all(&root).expect("create temp root");
let temp_name = std::ffi::OsStr::new(".report.json.canic-tmp-test");
let file_name = std::ffi::OsStr::new("report.json");
let temp_path = root.join(temp_name);
let path = root.join(file_name);
fs::write(&temp_path, b"complete contents").expect("write staged contents");
publish_create_new_after_error(&root, temp_name, file_name, error)
.expect("publish through hard-link fallback");
assert_eq!(fs::read(&path).expect("read target"), b"complete contents");
assert!(!temp_path.exists());
fs::remove_dir_all(root).expect("remove temp root");
}
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn link_publication_fallback_cannot_replace_an_existing_file() {
let root = temp_root("create-new-link-race");
fs::create_dir_all(&root).expect("create temp root");
let temp_name = std::ffi::OsStr::new(".report.json.canic-tmp-test");
let file_name = std::ffi::OsStr::new("report.json");
let temp_path = root.join(temp_name);
let path = root.join(file_name);
fs::write(&temp_path, b"our complete contents").expect("write staged contents");
fs::write(&path, b"raced complete contents").expect("write winning target");
let error =
publish_create_new_after_error(&root, temp_name, file_name, rustix::io::Errno::INVAL)
.expect_err("link fallback must reject an existing destination");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
fs::read(&path).expect("read winning target"),
b"raced complete contents"
);
assert_eq!(
fs::read(&temp_path).expect("read preserved staging"),
b"our complete contents"
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn new_parent_failures_never_create_the_final_file_or_staging() {
let steps = [
FileCommitStep::ParentDirectoryCreate,
FileCommitStep::CreatedDirectorySync,
FileCommitStep::CreatedDirectoryParentSync,
];
for step in steps {
let root = temp_root(&format!("parent-{step:?}"));
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("reports/nested/report.json");
let mut failed = false;
let error = commit_with_hook(
&path,
b"complete contents",
FileCommitMode::Replace,
|current, _| {
if current == step && !failed {
failed = true;
return Err(io::Error::other("injected parent persistence failure"));
}
Ok(())
},
)
.expect_err("injected parent step must fail");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert!(!path.exists());
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
}
#[test]
fn failed_replacement_removes_its_staging_file() {
let root = temp_root("replace-error");
let path = root.join("occupied");
fs::create_dir_all(&path).expect("create non-file destination");
fs::write(path.join("child"), b"preserved").expect("write destination child");
write_bytes(&path, b"new contents").expect_err("non-empty directory must reject replacement");
assert_eq!(
fs::read(path.join("child")).expect("read destination child"),
b"preserved"
);
assert_no_temporary_files(&root);
fs::remove_dir_all(root).expect("remove temp root");
}
fn assert_no_temporary_files(root: &Path) {
if !root.exists() {
return;
}
for entry in fs::read_dir(root).expect("read test directory") {
let entry = entry.expect("read test entry");
let path = entry.path();
if path.is_dir() {
assert_no_temporary_files(&path);
} else {
assert!(
!entry.file_name().to_string_lossy().contains(".canic-tmp-"),
"temporary file remains at {}",
path.display()
);
}
}
}
fn temp_root(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time after unix epoch")
.as_nanos();
std::env::temp_dir().canonicalize().unwrap().join(format!(
"canic-host-durable-io-{label}-{}-{nanos}",
std::process::id()
))
}
#[test]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
fn private_publication_is_owner_only_before_writing_and_never_replaces() {
use std::os::unix::fs::PermissionsExt as _;
let root = temp_root("private-publication");
let path = root.join("private/key");
commit_with_hook(
&path,
&[7; 32],
FileCommitMode::CreatePrivateWithParents,
|step, observed| {
if step == FileCommitStep::TemporaryFileWrite {
assert_eq!(fs::metadata(observed)?.permissions().mode() & 0o777, 0o600);
}
Ok(())
},
)
.unwrap();
assert_eq!(read_private_bytes::<32>(&path), Some([7; 32]));
let error = create_private_bytes_with_parents(&path, &[8; 32]).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(read_private_bytes::<32>(&path), Some([7; 32]));
fs::remove_dir_all(root).unwrap();
}