#[cfg(test)]
use core::cell::{Cell, RefCell};
use core::hash::{BuildHasher as _, Hasher as _};
use core::sync::atomic::{AtomicU64, Ordering};
use std::collections::hash_map::RandomState;
use std::fs::{self, File};
use std::io::{self, ErrorKind, Write as _};
use camino::{Utf8Path, Utf8PathBuf};
use crate::Result;
use crate::error::error;
pub fn write(path: &Utf8Path, contents: &str) -> Result<()> {
let destination = crate::paths::physical(path)?;
create_parents(&destination)?;
replace(path, &destination, write_bytes(contents))
}
pub(crate) fn write_streamed(path: &Utf8Path, fill: impl FnOnce(&mut dyn io::Write) -> io::Result<()>) -> Result<()> {
let destination = crate::paths::physical(path)?;
create_parents(&destination)?;
replace(path, &destination, fill)
}
pub(crate) fn write_if_unchanged(workspace: &Utf8Path, path: &Utf8Path, expected: Option<&str>, contents: &str) -> Result<Publication> {
let destination = crate::paths::physical(path)?;
create_parents(&destination)?;
let _lock = crate::exec::claim_workspace(workspace)?;
let scratch = scratch_path(&destination);
stage(&scratch, write_bytes(contents), Some(&destination)).map_err(|cause| discard(&scratch, path, cause))?;
before_publication(&scratch);
if !matches_contents(&destination, expected).map_err(|cause| error!("could not check `{path}` before replacing it").caused_by(cause))? {
remove_staging(&scratch, path)?;
return Ok(Publication::Conflict);
}
after_comparison(&destination);
fs::rename(scratch.as_std_path(), destination.as_std_path()).map_err(|cause| discard(&scratch, path, cause))?;
match published(&destination) {
Ok(()) => Ok(Publication::Published),
Err(cause) => Ok(Publication::PublishedUndurable(error!("could not write `{path}`").caused_by(cause))),
}
}
pub(crate) fn remove_if_unchanged(workspace: &Utf8Path, path: &Utf8Path, expected: &str) -> Result<Publication> {
let destination = crate::paths::physical(path)?;
let _lock = crate::exec::claim_workspace(workspace)?;
before_publication(&destination);
if !matches_contents(&destination, Some(expected))
.map_err(|cause| error!("could not check `{path}` before removing it").caused_by(cause))?
{
return Ok(Publication::Conflict);
}
fs::remove_file(destination.as_std_path()).map_err(|cause| error!("could not remove `{path}`").caused_by(cause))?;
match published(&destination) {
Ok(()) => Ok(Publication::Published),
Err(cause) => Ok(Publication::PublishedUndurable(
error!("could not remove `{path}`").caused_by(cause),
)),
}
}
#[derive(Debug)]
pub(crate) enum Publication {
Conflict,
Published,
PublishedUndurable(crate::error::Error),
}
fn replace(path: &Utf8Path, destination: &Utf8Path, fill: impl FnOnce(&mut dyn io::Write) -> io::Result<()>) -> Result<()> {
let scratch = scratch_path(destination);
stage(&scratch, fill, Some(destination)).map_err(|cause| discard(&scratch, path, cause))?;
before_publication(&scratch);
fs::rename(scratch.as_std_path(), destination.as_std_path()).map_err(|cause| discard(&scratch, path, cause))?;
published(destination).map_err(|cause| error!("could not write `{path}`").caused_by(cause))
}
pub fn publish(path: &Utf8Path, contents: &str) -> Result<bool> {
create_parents(path)?;
let scratch = scratch_path(path);
stage(&scratch, write_bytes(contents), None).map_err(|cause| discard(&scratch, path, cause))?;
let linked = match fs::hard_link(scratch.as_std_path(), path.as_std_path()) {
Ok(()) => true,
Err(cause) if cause.kind() == ErrorKind::AlreadyExists => false,
Err(cause) => return Err(discard(&scratch, path, cause)),
};
let durability = linked.then(|| published(path)).transpose();
if let Err(cause) = fs::remove_file(scratch.as_std_path()) {
crate::notes::note(format!("`{scratch}` was left behind and could not be removed: {cause}"));
}
if let Err(cause) = durability {
return Err(error!("could not write `{path}`").caused_by(cause));
}
Ok(linked)
}
fn create_parents(path: &Utf8Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent.as_std_path()).map_err(|cause| error!("could not create `{parent}`").caused_by(cause))?;
}
Ok(())
}
fn matches_contents(path: &Utf8Path, expected: Option<&str>) -> io::Result<bool> {
match fs::read(path.as_std_path()) {
Ok(actual) => Ok(expected.is_some_and(|expected| actual == expected.as_bytes())),
Err(cause) if cause.kind() == ErrorKind::NotFound => Ok(expected.is_none()),
Err(cause) => Err(cause),
}
}
fn remove_staging(scratch: &Utf8Path, path: &Utf8Path) -> Result<()> {
match fs::remove_file(scratch.as_std_path()) {
Ok(()) => Ok(()),
Err(cause) if cause.kind() == ErrorKind::NotFound => Ok(()),
Err(cause) => Err(error!("`{path}` changed before it could be replaced, and `{scratch}` could not be removed").caused_by(cause)),
}
}
fn stage(scratch: &Utf8Path, fill: impl FnOnce(&mut dyn io::Write) -> io::Result<()>, mode: Option<&Utf8Path>) -> io::Result<()> {
let mut staging = File::create_new(scratch.as_std_path())?;
{
let mut writer = io::BufWriter::new(&mut staging);
fill(&mut writer)?;
writer.flush()?;
}
if let Some(permissions) = mode
.and_then(|path| fs::metadata(path.as_std_path()).ok())
.map(|metadata| metadata.permissions())
{
staging.set_permissions(permissions)?;
}
staging.sync_all()
}
fn write_bytes(contents: &str) -> impl FnOnce(&mut dyn io::Write) -> io::Result<()> + '_ {
move |writer| writer.write_all(contents.as_bytes())
}
#[cfg(unix)]
fn published(path: &Utf8Path) -> io::Result<()> {
#[cfg(test)]
if take_directory_sync_failure() {
return Err(io::Error::other("injected directory sync failure"));
}
let parent = path.parent().filter(|parent| !parent.as_str().is_empty());
File::open(parent.unwrap_or_else(|| Utf8Path::new(".")).as_std_path())?.sync_all()
}
#[cfg(all(not(unix), test))]
fn published(_path: &Utf8Path) -> io::Result<()> {
if take_directory_sync_failure() {
return Err(io::Error::other("injected directory sync failure"));
}
Ok(())
}
#[cfg(all(not(unix), not(test)))]
#[expect(clippy::unnecessary_wraps, reason = "the Unix spelling is fallible, and the two must agree")]
const fn published(_path: &Utf8Path) -> io::Result<()> {
Ok(())
}
fn discard(scratch: &Utf8Path, path: &Utf8Path, cause: io::Error) -> crate::error::Error {
let failed = error!("could not write `{path}`").caused_by(cause);
match fs::remove_file(scratch.as_std_path()) {
Ok(()) => failed,
Err(removal) if removal.kind() == ErrorKind::NotFound => failed,
Err(removal) => error!("{failed}; and `{scratch}` could not be removed either: {removal}"),
}
}
pub(crate) fn scratch_path(path: &Utf8Path) -> Utf8PathBuf {
static NEXT: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
if let Some(scratch) = TEST_SCRATCH.with(|next| next.borrow_mut().take()) {
return scratch;
}
let name = path.file_name().unwrap_or("report");
let invocation = NEXT.fetch_add(1, Ordering::Relaxed);
let mut hasher = RandomState::new().build_hasher();
hasher.write_u32(std::process::id());
hasher.write_u64(invocation);
path.with_file_name(format!(".{name}.{}.{invocation}.{:016x}.tmp", std::process::id(), hasher.finish()))
}
#[cfg(test)]
type PublicationHook = Box<dyn FnOnce(&Utf8Path)>;
#[cfg(test)]
thread_local! {
static BEFORE_PUBLICATION: RefCell<Option<PublicationHook>> = const { RefCell::new(None) };
static AFTER_COMPARISON: RefCell<Option<PublicationHook>> = const { RefCell::new(None) };
static TEST_SCRATCH: RefCell<Option<Utf8PathBuf>> = const { RefCell::new(None) };
static DIRECTORY_SYNC_FAILURE: Cell<bool> = const { Cell::new(false) };
}
#[cfg(test)]
pub(crate) fn before_next_publication(hook: impl FnOnce(&Utf8Path) + 'static) {
BEFORE_PUBLICATION.with(|next| *next.borrow_mut() = Some(Box::new(hook)));
}
#[cfg(test)]
fn after_next_comparison(hook: impl FnOnce(&Utf8Path) + 'static) {
AFTER_COMPARISON.with(|next| *next.borrow_mut() = Some(Box::new(hook)));
}
#[cfg(test)]
pub(crate) fn next_scratch_path(scratch: Utf8PathBuf) {
TEST_SCRATCH.with(|next| *next.borrow_mut() = Some(scratch));
}
#[cfg(test)]
pub(crate) fn fail_next_directory_sync() {
DIRECTORY_SYNC_FAILURE.with(|next| next.set(true));
}
#[cfg(test)]
fn take_directory_sync_failure() -> bool {
DIRECTORY_SYNC_FAILURE.with(|next| next.replace(false))
}
#[cfg(test)]
fn before_publication(path: &Utf8Path) {
let hook = BEFORE_PUBLICATION.with(|next| next.borrow_mut().take());
if let Some(hook) = hook {
hook(path);
}
}
#[cfg(not(test))]
const fn before_publication(_path: &Utf8Path) {}
#[cfg(test)]
fn after_comparison(path: &Utf8Path) {
let hook = AFTER_COMPARISON.with(|next| next.borrow_mut().take());
if let Some(hook) = hook {
hook(path);
}
}
#[cfg(not(test))]
const fn after_comparison(_path: &Utf8Path) {}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use std::process::Command;
use std::sync::{Arc, Barrier, Mutex};
use std::thread;
use super::*;
const EDITOR_PATH: &str = "CARGO_GAMMA_PUBLICATION_EDITOR_PATH";
fn edit_in_child(path: &Utf8Path) {
let status = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"elements::publication::tests::conditional_publication_has_a_deterministic_external_editor_boundary",
"--nocapture",
])
.env(EDITOR_PATH, path)
.status()
.expect("external editor process");
assert!(status.success(), "{status}");
}
#[test]
fn writing_a_report_creates_parent_directories() {
let directory = crate::testing::workdir("elements-write");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let parent = root.join("nested").join("deeper");
let path = parent.join("report.json");
write(&path, "{}").expect("write report");
assert!(parent.is_dir(), "the parent directory was not created");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read report"), "{}");
}
#[test]
fn a_streamed_write_publishes_the_streamed_bytes_whole() {
let directory = crate::testing::workdir("elements-stream-ok-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let path = root.join("nested").join("report.json");
write_streamed(&path, |writer| {
writer.write_all(b"{\"schemaVersion\":")?;
writer.write_all(b"\"2\"}")
})
.expect("stream report");
assert_eq!(
fs::read_to_string(path.as_std_path()).expect("published bytes"),
"{\"schemaVersion\":\"2\"}"
);
}
#[test]
fn a_streamed_write_that_fails_midway_leaves_the_previous_file_and_no_staging_litter() {
let directory = crate::testing::workdir("elements-stream-fail-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let path = root.join("report.json");
fs::write(path.as_std_path(), "the original").expect("seed the destination");
let error = write_streamed(&path, |writer| {
writer.write_all(b"half a document")?;
Err(io::Error::other("the filler gave up"))
})
.expect_err("a filler failure must surface");
assert!(error.to_string().contains("could not write"), "{error}");
assert!(error.to_string().contains("the filler gave up"), "{error}");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("previous bytes"), "the original");
let entries: Vec<String> = fs::read_dir(root.as_std_path())
.expect("read the directory")
.map(|entry| entry.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, ["report.json"], "the staging sibling must be cleaned up: {entries:?}");
}
#[test]
fn a_conditional_replacement_reports_post_rename_sync_failure_as_published() {
let directory = crate::testing::workdir("elements-conditional-sync-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf-8");
let path = root.join("source.rs");
fs::write(path.as_std_path(), "before").expect("source");
fail_next_directory_sync();
let publication = write_if_unchanged(&root, &path, Some("before"), "after").expect("the rename itself succeeds");
assert!(matches!(publication, Publication::PublishedUndurable(_)), "{publication:?}");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("published bytes"), "after");
assert!(
!root.join(".source.rs.cargo-gamma.lock").exists(),
"publication locks belong in the external workspace cache"
);
}
#[test]
fn a_conditional_removal_reports_post_unlink_sync_failure_as_published() {
let directory = crate::testing::workdir("elements-conditional-remove-sync-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf-8");
let path = root.join("source.rs");
fs::write(path.as_std_path(), "before").expect("source");
fail_next_directory_sync();
let publication = remove_if_unchanged(&root, &path, "before").expect("the removal itself succeeds");
assert!(matches!(publication, Publication::PublishedUndurable(_)), "{publication:?}");
assert!(!path.exists(), "the name was removed before the directory sync failed");
}
#[test]
fn conditional_publication_has_a_deterministic_external_editor_boundary() {
if let Some(path) = std::env::var_os(EDITOR_PATH) {
fs::write(path, "editor").expect("external editor writes");
return;
}
let directory = crate::testing::workdir("elements-external-editor-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf-8");
let path = root.join("source.rs");
fs::write(path.as_std_path(), "before").expect("source");
let edited = path.clone();
before_next_publication(move |_| edit_in_child(&edited));
let conflict = write_if_unchanged(&root, &path, Some("before"), "gamma").expect("comparison");
assert!(matches!(conflict, Publication::Conflict), "{conflict:?}");
assert_eq!(fs::read_to_string(&path).expect("editor bytes"), "editor");
fs::write(path.as_std_path(), "before").expect("reset source");
let edited = path.clone();
after_next_comparison(move |_| edit_in_child(&edited));
let published = write_if_unchanged(&root, &path, Some("before"), "gamma").expect("publication");
assert!(matches!(published, Publication::Published), "{published:?}");
assert_eq!(
fs::read_to_string(&path).expect("published bytes"),
"gamma",
"the API must not claim to preserve non-cooperating edits made after its comparison"
);
}
#[test]
fn a_publish_sync_failure_removes_its_staging_file() {
let directory = crate::testing::workdir("elements-publish-sync-failure-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf-8");
let path = root.join("gamma.toml");
fail_next_directory_sync();
let error = publish(&path, "jobs = 2\n").expect_err("directory sync must fail");
let entries: Vec<_> = fs::read_dir(&root)
.expect("directory")
.map(|entry| entry.expect("entry").file_name())
.collect();
assert!(error.to_string().contains("injected directory sync failure"), "{error}");
assert_eq!(fs::read_to_string(&path).expect("published destination"), "jobs = 2\n");
assert_eq!(entries, vec![path.file_name().expect("name")]);
}
#[test]
fn a_report_is_written_whole_rather_than_streamed_into_its_destination() {
let dir = tempfile::TempDir::new().expect("temp");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("nested").join("report.json");
write(&path, "{\"first\":true}").expect("first write");
write(&path, "{\"second\":true}").expect("second write");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read"), "{\"second\":true}");
let leftovers: Vec<String> = fs::read_dir(path.parent().expect("parent").as_std_path())
.expect("read dir")
.map(|entry| entry.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(leftovers, vec!["report.json".to_owned()], "the staging file must not survive");
}
#[test]
fn a_write_that_cannot_be_completed_leaves_the_previous_file_alone() {
let dir = tempfile::TempDir::new().expect("temp");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
fs::create_dir(path.as_std_path()).expect("a destination the rename cannot replace");
assert!(write(&path, "{}").is_err(), "the failure must be reported rather than swallowed");
assert!(path.as_std_path().is_dir(), "the destination must be untouched");
let leftovers = fs::read_dir(root.as_std_path()).expect("read dir").count();
assert_eq!(leftovers, 1, "a failed rename must not leave its staging file behind");
}
#[test]
fn a_path_with_no_parent_at_all_skips_directory_creation() {
let path = Utf8PathBuf::new();
assert!(path.parent().is_none());
assert!(write(&path, "{}").is_err());
}
#[test]
fn a_write_that_cannot_be_staged_leaves_the_previous_contents_alone() {
let dir = crate::testing::workdir("elements-staging-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
fs::write(path.as_std_path(), "{\"original\":true}").expect("the original");
let scratch = root.join(".blocked-stage");
fs::create_dir(scratch.as_std_path()).expect("something the staging file cannot be");
next_scratch_path(scratch);
let error = write(&path, "{\"replacement\":true}").expect_err("the write must fail");
assert!(error.to_string().contains("report.json"), "{error}");
assert_eq!(
fs::read_to_string(path.as_std_path()).expect("read"),
"{\"original\":true}",
"the previous contents were not left alone"
);
}
#[cfg(unix)]
#[test]
fn a_replaced_file_keeps_the_permissions_it_had() {
use std::os::unix::fs::PermissionsExt as _;
let dir = crate::testing::workdir("elements-permissions-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("source.rs");
fs::write(path.as_std_path(), "fn f() {}\n").expect("the original");
fs::set_permissions(path.as_std_path(), fs::Permissions::from_mode(0o640)).expect("mode");
write(&path, "fn g() {}\n").expect("write");
let mode = fs::metadata(path.as_std_path()).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o640, "the file came back with different permissions");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read"), "fn g() {}\n");
}
#[cfg(unix)]
#[test]
fn a_write_through_a_symlink_replaces_what_it_points_at() {
let dir = crate::testing::workdir("elements-symlink-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let target = root.join("real.rs");
let link = root.join("link.rs");
fs::write(target.as_std_path(), "fn f() {}\n").expect("the original");
std::os::unix::fs::symlink(target.as_std_path(), link.as_std_path()).expect("symlink");
write(&link, "fn g() {}\n").expect("write");
assert!(
fs::symlink_metadata(link.as_std_path()).expect("metadata").file_type().is_symlink(),
"the link was replaced by a file"
);
assert_eq!(fs::read_to_string(target.as_std_path()).expect("read"), "fn g() {}\n");
}
#[cfg(unix)]
#[test]
fn a_write_through_a_dangling_symlink_preserves_the_link_and_creates_its_target() {
let dir = crate::testing::workdir("elements-dangling-symlink-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let target = root.join("created").join("real.rs");
let link = root.join("link.rs");
std::os::unix::fs::symlink("created/real.rs", link.as_std_path()).expect("symlink");
write(&link, "fn g() {}\n").expect("write");
assert!(
fs::symlink_metadata(link.as_std_path()).expect("metadata").file_type().is_symlink(),
"the dangling link was replaced"
);
assert_eq!(fs::read_to_string(target.as_std_path()).expect("target"), "fn g() {}\n");
}
#[test]
fn publishing_takes_a_free_name_and_leaves_a_taken_one_alone() {
let dir = crate::testing::workdir("elements-publish-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("nested").join("gamma.toml");
assert!(publish(&path, "jobs = 2\n").expect("publish"), "a free name must be taken");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read"), "jobs = 2\n");
assert!(
!publish(&path, "jobs = 9\n").expect("publish"),
"a name that is taken must be reported, not overwritten"
);
assert_eq!(
fs::read_to_string(path.as_std_path()).expect("read"),
"jobs = 2\n",
"the file that was already there was overwritten"
);
let leftovers: Vec<String> = fs::read_dir(path.parent().expect("parent").as_std_path())
.expect("read dir")
.map(|entry| entry.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(leftovers, vec!["gamma.toml".to_owned()], "the staging file must not survive");
}
#[test]
fn a_publish_that_cannot_be_staged_leaves_no_file_to_block_the_retry() {
let dir = crate::testing::workdir("elements-publish-failure-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("gamma.toml");
let scratch = root.join(".blocked-stage");
fs::create_dir(scratch.as_std_path()).expect("something the staging file cannot be");
next_scratch_path(scratch.clone());
let error = publish(&path, "jobs = 2\n").expect_err("the publish must fail");
assert!(error.to_string().contains("gamma.toml"), "{error}");
assert!(!path.as_std_path().exists(), "a failed publish left the final name taken");
fs::remove_dir(scratch.as_std_path()).expect("clear the obstruction");
assert!(publish(&path, "jobs = 2\n").expect("retry"));
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read"), "jobs = 2\n");
}
#[test]
fn a_staging_file_that_cannot_be_removed_is_reported_with_the_failure() {
let dir = crate::testing::workdir("elements-discard-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
let scratch = root.join(".unremovable-stage");
fs::create_dir(scratch.as_std_path()).expect("an unremovable staging path");
next_scratch_path(scratch.clone());
let error = write(&path, "{}").expect_err("the write must fail");
assert!(error.to_string().contains("could not be removed"), "{error}");
assert!(error.to_string().contains(scratch.file_name().expect("name")), "{error}");
}
#[test]
fn a_staging_name_that_is_already_taken_is_refused_rather_than_written_through() {
let dir = crate::testing::workdir("elements-exclusive-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
fs::write(path.as_std_path(), "{\"original\":true}").expect("the original");
let scratch = root.join(".taken-stage");
fs::write(scratch.as_std_path(), "somebody else's staging file").expect("the staging file");
next_scratch_path(scratch);
let error = write(&path, "{\"replacement\":true}").expect_err("the write must fail");
assert!(error.to_string().contains("report.json"), "{error}");
assert_eq!(
fs::read_to_string(path.as_std_path()).expect("read"),
"{\"original\":true}",
"the destination was published from a staging file this call did not create"
);
}
#[test]
fn a_staging_name_is_not_derived_from_the_process_id_alone() {
let path = Utf8Path::new("/w/report.json");
let first = scratch_path(path).file_name().expect("a name").to_owned();
let second = scratch_path(path).file_name().expect("a name").to_owned();
assert_ne!(first, second, "two invocations must not share a staging path");
assert_ne!(first, format!(".report.json.{}.tmp", std::process::id()));
assert!(first.contains(&std::process::id().to_string()), "{first}");
assert!(second.contains(&std::process::id().to_string()), "{second}");
}
#[test]
fn concurrent_writers_neither_remove_nor_publish_another_writers_staging_file() {
let dir = crate::testing::workdir("elements-concurrent-staging-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
let barrier = Arc::new(Barrier::new(3));
let staged = Arc::new(Mutex::new(Vec::new()));
let mut writers = Vec::new();
for contents in ["first", "second", "third"] {
let barrier = Arc::clone(&barrier);
let staged = Arc::clone(&staged);
let path = path.clone();
writers.push(thread::spawn(move || {
before_next_publication(move |scratch| {
assert_eq!(fs::read_to_string(scratch).expect("staged bytes"), contents);
staged.lock().expect("staged paths").push(scratch.to_path_buf());
let _ = barrier.wait();
});
write(&path, contents)
}));
}
for writer in writers {
writer.join().expect("writer panicked").expect("write");
}
let mut staged = staged.lock().expect("staged paths").clone();
staged.sort();
staged.dedup();
assert_eq!(staged.len(), 3, "every invocation must own its staging path");
assert!(["first", "second", "third"].contains(&fs::read_to_string(path).expect("published bytes").as_str()));
}
#[cfg(unix)]
#[test]
fn the_directory_a_published_name_lives_in_is_synced_too() {
let dir = crate::testing::workdir("elements-durable-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
write(&path, "{}").expect("write");
published(&path).expect("the parent of a written file is syncable");
assert!(published(&root.join("absent").join("report.json")).is_err());
published(Utf8Path::new("report.json")).expect("a relative name syncs the working directory");
}
#[cfg(unix)]
#[test]
fn a_read_only_destination_is_replaced_and_stays_read_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = crate::testing::workdir("elements-read-only-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
let path = root.join("report.json");
fs::write(path.as_std_path(), "{\"original\":true}").expect("the original");
fs::set_permissions(path.as_std_path(), fs::Permissions::from_mode(0o444)).expect("mode");
write(&path, "{\"replacement\":true}").expect("write");
let mode = fs::metadata(path.as_std_path()).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o444, "the file came back with different permissions");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("read"), "{\"replacement\":true}");
}
}