use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static ASIDE_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub(crate) struct OwnershipGuard {
moved: Vec<(PathBuf, PathBuf)>,
}
impl OwnershipGuard {
pub(crate) fn inert() -> Self {
Self { moved: Vec::new() }
}
pub(crate) fn move_aside(bin_dir: &Path, binaries: &[String]) -> anyhow::Result<Self> {
let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new();
for name in binaries {
let dest = bin_dir.join(name);
sweep_stale_asides(bin_dir, name, &dest);
if !dest.exists() {
continue;
}
let seq = ASIDE_SEQ.fetch_add(1, Ordering::Relaxed);
let aside = bin_dir.join(format!(".{name}.pre-cargo.{}.{seq}", std::process::id()));
if let Err(e) = std::fs::rename(&dest, &aside) {
if e.kind() == std::io::ErrorKind::NotFound {
continue;
}
let rollback = restore_pairs(&moved);
let mut err = anyhow::anyhow!(
"cargo ownership guard could not move {} aside: {e}",
dest.display()
);
if let Err(r) = rollback {
err = err.context(format!("and rollback was incomplete: {r}"));
}
return Err(err);
}
tracing::debug!(
dest = %dest.display(),
aside = %aside.display(),
"moved untracked binary aside before cargo install (#5777)"
);
moved.push((dest, aside));
}
Ok(Self { moved })
}
pub(crate) fn commit(mut self) -> anyhow::Result<()> {
let moved = std::mem::take(&mut self.moved);
let mut failures: Vec<String> = Vec::new();
for (dest, aside) in &moved {
if dest.exists() {
if let Err(e) = std::fs::remove_file(aside) {
failures.push(format!("could not remove aside {}: {e}", aside.display()));
}
} else if let Err(e) = std::fs::rename(aside, dest) {
failures.push(format!(
"cargo wrote nothing at {} and the aside copy {} could not be \
restored: {e}",
dest.display(),
aside.display()
));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!(
"cargo ownership guard settle incomplete: {}",
failures.join("; ")
))
}
}
pub(crate) fn restore(mut self) -> anyhow::Result<()> {
let moved = std::mem::take(&mut self.moved);
restore_pairs(&moved)
}
}
impl Drop for OwnershipGuard {
fn drop(&mut self) {
if self.moved.is_empty() {
return;
}
let moved = std::mem::take(&mut self.moved);
match restore_pairs(&moved) {
Ok(()) => tracing::warn!(
restored = moved.len(),
"cargo ownership guard dropped without settling; asides \
restored from Drop (#5777)"
),
Err(e) => tracing::error!(
error = %e,
"cargo ownership guard dropped without settling and the \
Drop restore was incomplete — aside copies remain on disk \
(#5777)"
),
}
}
}
fn restore_pairs(moved: &[(PathBuf, PathBuf)]) -> anyhow::Result<()> {
let mut failures: Vec<String> = Vec::new();
for (dest, aside) in moved {
if let Err(e) = std::fs::rename(aside, dest) {
failures.push(format!(
"could not restore {} from {}: {e}",
dest.display(),
aside.display()
));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!(
"cargo ownership guard restore incomplete (aside copies left on \
disk): {}",
failures.join("; ")
))
}
}
fn sweep_stale_asides(bin_dir: &Path, name: &str, dest: &Path) {
let prefix = format!(".{name}.pre-cargo.");
let entries = match std::fs::read_dir(bin_dir) {
Ok(entries) => entries,
Err(e) => {
tracing::debug!(
bin_dir = %bin_dir.display(),
"stale-aside sweep skipped, could not read dir: {e}"
);
return;
}
};
let mut stale: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let file_name = entry.file_name().to_string_lossy().into_owned();
let rest = file_name.strip_prefix(&prefix)?;
let pid: u32 = rest.split('.').next()?.parse().ok()?;
(pid != std::process::id() && !pid_is_alive(pid)).then(|| entry.path())
})
.collect();
stale.sort();
let mut restored_this_sweep = false;
for aside in stale {
if dest.exists() {
match std::fs::remove_file(&aside) {
Ok(()) if restored_this_sweep => tracing::warn!(
aside = %aside.display(),
dest = %dest.display(),
"discarded an ADDITIONAL stale pre-cargo aside — an \
earlier aside was already restored to the destination \
this sweep and the two copies may differ (#5778)"
),
Ok(()) => tracing::warn!(
aside = %aside.display(),
dest = %dest.display(),
"deleted stale pre-cargo aside litter from a dead process (#5777)"
),
Err(e) => tracing::warn!(
aside = %aside.display(),
"could not delete stale pre-cargo aside litter: {e}"
),
}
} else {
match std::fs::rename(&aside, dest) {
Ok(()) => {
restored_this_sweep = true;
tracing::warn!(
aside = %aside.display(),
dest = %dest.display(),
"recovered stale pre-cargo aside from a dead process (#5777)"
);
}
Err(e) => tracing::warn!(
aside = %aside.display(),
"could not restore stale pre-cargo aside: {e}"
),
}
}
}
}
#[cfg(unix)]
fn pid_is_alive(pid: u32) -> bool {
let pid = libc::pid_t::try_from(pid).unwrap_or(-1);
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
#[cfg(not(unix))]
fn pid_is_alive(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, content: &str) {
std::fs::write(path, content).expect("write fixture");
}
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn move_aside_then_commit_removes_asides() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tm"), "old-tm");
write(&tmp.path().join("trusty-mpm"), "old-mpm");
let guard =
OwnershipGuard::move_aside(tmp.path(), &names(&["tm", "trusty-mpm"])).expect("move");
assert!(!tmp.path().join("tm").exists(), "destination must be clear");
write(&tmp.path().join("tm"), "new-tm");
write(&tmp.path().join("trusty-mpm"), "new-mpm");
guard.commit().expect("commit");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tm")).expect("read"),
"new-tm"
);
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with('.'))
.collect();
assert!(
leftovers.is_empty(),
"no aside litter after commit: {leftovers:?}"
);
}
#[test]
fn restore_puts_every_binary_back() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tctl"), "old-tctl");
write(&tmp.path().join("trusty-installer"), "old-ti");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tctl", "trusty-installer"]))
.expect("move");
assert!(!tmp.path().join("tctl").exists());
guard.restore().expect("restore");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tctl")).expect("read"),
"old-tctl"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("trusty-installer")).expect("read"),
"old-ti"
);
}
#[test]
fn commit_restores_when_cargo_skipped_writing() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tagent"), "only-copy");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tagent"])).expect("move");
guard.commit().expect("commit");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tagent")).expect("read"),
"only-copy",
"the skip case must restore the aside, never delete the only copy"
);
}
#[test]
fn move_aside_skips_missing_binaries() {
let tmp = tempfile::tempdir().expect("tempdir");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["not-there"])).expect("move");
guard.commit().expect("commit is a no-op");
}
#[test]
fn concurrent_guards_never_lose_the_binary() {
let tmp = tempfile::tempdir().expect("tempdir");
let bin = tmp.path().join("trusty-search");
write(&bin, "v1");
std::thread::scope(|s| {
for _ in 0..8 {
let dir = tmp.path().to_path_buf();
s.spawn(move || {
for _ in 0..25 {
let guard = OwnershipGuard::move_aside(&dir, &names(&["trusty-search"]))
.expect("move_aside must not error");
guard.restore().expect("restore must not error");
}
});
}
});
assert_eq!(
std::fs::read_to_string(&bin).expect("binary must survive the race"),
"v1"
);
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with('.'))
.collect();
assert!(
leftovers.is_empty(),
"no aside litter after the race: {leftovers:?}"
);
}
#[test]
fn concurrent_commit_and_restore_keep_a_valid_binary() {
let tmp = tempfile::tempdir().expect("tempdir");
let bin = tmp.path().join("trusty-search");
write(&bin, "v1");
std::thread::scope(|s| {
for i in 0..8 {
let dir = tmp.path().to_path_buf();
s.spawn(move || {
for _ in 0..25 {
let guard = OwnershipGuard::move_aside(&dir, &names(&["trusty-search"]))
.expect("move_aside must not error");
if i % 2 == 0 {
write(&dir.join("trusty-search"), "v2");
guard.commit().expect("commit must not error");
} else {
guard.restore().expect("restore must not error");
}
}
});
}
});
let survivor = std::fs::read_to_string(&bin).expect("binary must survive the race");
assert!(
survivor == "v1" || survivor == "v2",
"surviving binary must be one of the two valid copies, got {survivor:?}"
);
assert!(
hidden_entries(tmp.path()).is_empty(),
"no aside litter after the commit/restore race"
);
}
fn hidden_entries(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.expect("read_dir")
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with('.'))
.collect()
}
#[cfg(unix)]
fn dead_pid() -> u32 {
let mut child = std::process::Command::new("true")
.spawn()
.expect("spawn `true`");
let pid = child.id();
child.wait().expect("wait for `true`");
pid
}
#[test]
fn dropped_guard_restores_its_asides() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tm"), "only-copy");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tm"])).expect("move");
assert!(!tmp.path().join("tm").exists(), "destination cleared");
drop(guard);
assert_eq!(
std::fs::read_to_string(tmp.path().join("tm")).expect("read"),
"only-copy",
"Drop must restore an unsettled guard's asides"
);
assert!(
hidden_entries(tmp.path()).is_empty(),
"no aside litter after Drop"
);
}
#[test]
fn drop_after_commit_never_restores_over_cargo_output() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tga"), "old");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tga"])).expect("move");
write(&tmp.path().join("tga"), "new"); guard.commit().expect("commit");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tga")).expect("read"),
"new",
"Drop after commit must not restore the old binary"
);
assert!(hidden_entries(tmp.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn sweep_restores_stale_aside_when_destination_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let stale = tmp.path().join(format!(".tctl.pre-cargo.{}.7", dead_pid()));
write(&stale, "stranded-only-copy");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tctl"])).expect("move");
guard.restore().expect("restore");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tctl")).expect("read"),
"stranded-only-copy",
"a dead-pid aside with no destination must be restored"
);
assert!(hidden_entries(tmp.path()).is_empty(), "no litter remains");
}
#[cfg(unix)]
#[test]
fn sweep_deletes_stale_aside_when_destination_exists() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join("tagent"), "current");
let stale = tmp
.path()
.join(format!(".tagent.pre-cargo.{}.3", dead_pid()));
write(&stale, "old-litter");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tagent"])).expect("move");
guard.restore().expect("restore");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tagent")).expect("read"),
"current",
"the live destination must win over dead-pid litter"
);
assert!(hidden_entries(tmp.path()).is_empty(), "litter deleted");
}
#[cfg(unix)]
#[test]
fn sweep_restores_first_stale_aside_and_discards_the_rest() {
let tmp = tempfile::tempdir().expect("tempdir");
let pid = dead_pid();
write(
&tmp.path().join(format!(".tctl.pre-cargo.{pid}.3")),
"first-copy",
);
write(
&tmp.path().join(format!(".tctl.pre-cargo.{pid}.7")),
"second-copy",
);
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tctl"])).expect("move");
guard.restore().expect("restore");
assert_eq!(
std::fs::read_to_string(tmp.path().join("tctl")).expect("read"),
"first-copy",
"the sorted-first stale aside must win the restore"
);
assert!(
hidden_entries(tmp.path()).is_empty(),
"additional stale asides are discarded (with a warning), not kept"
);
}
#[cfg(unix)]
#[test]
fn sweep_leaves_live_process_asides_alone() {
let tmp = tempfile::tempdir().expect("tempdir");
let live = tmp
.path()
.join(format!(".tm.pre-cargo.{}.9999", std::process::id()));
write(&live, "owned-by-a-live-guard");
let guard = OwnershipGuard::move_aside(tmp.path(), &names(&["tm"])).expect("move");
guard.commit().expect("commit (nothing moved)");
assert_eq!(
std::fs::read_to_string(&live).expect("read live aside"),
"owned-by-a-live-guard",
"a live process's aside must never be swept or rewritten"
);
assert!(
!tmp.path().join("tm").exists(),
"a wrong sweep would have restored the live aside to `tm`"
);
}
}