#![cfg(unix)]
use super::*;
use crate::stale_ref_lock_killed_git as killed;
use crate::stale_ref_lock_killed_git::{Held, Helper, Stage, GONE_REF};
use crate::test_support::{git_output, remove};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, SystemTime};
fn judge() -> Judge {
return Judge { margin: Duration::ZERO, pid_view_complete: true, boot: None, ..Judge::here() };
}
fn ended_pid() -> i64 {
let mut child: Child = Command::new("true").spawn().expect("start true");
let pid: i64 = i64::from(child.id());
child.wait().expect("reap true");
return pid;
}
fn sleeper() -> Helper {
return Helper(Command::new("sleep").arg("120").stdin(Stdio::null()).spawn().expect("start sleep"));
}
fn plant_lock(stage: &Stage, reference: &str, pid: Option<i64>) {
killed::write_file(stage.lock_of(reference).as_path(), "");
if let Some(number) = pid {
std::thread::sleep(Duration::from_millis(20));
killed::write_file(stage.pid_file_of(reference).as_path(), &format!("pid {number}\n"));
}
}
const PLANTED_REF: &str = "refs/heads/planted";
fn leftovers(stage: &Stage) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
let mut pending: Vec<PathBuf> = vec![stage.git_dir.clone()];
while let Some(directory) = pending.pop() {
for entry in std::fs::read_dir(directory.as_path()).expect("read the directory").flatten() {
let path: PathBuf = entry.path();
if entry.file_type().expect("file type").is_dir() {
pending.push(path);
} else if path.to_string_lossy().ends_with(".lock") || path.ends_with("packed-refs.new") {
found.push(path);
}
}
}
found.sort();
return found;
}
fn verdict_of(outcome: &Outcome, lock: &Path) -> Option<Verdict> {
return outcome.cleared.iter().chain(outcome.left.iter()).find(|found| { return found.lock == lock; }).map(|found| { return found.verdict.clone(); });
}
fn fetch_works(stage: &Stage) {
let _removed = std::fs::remove_file(stage.git_dir.join("hooks").join("reference-transaction"));
let fetched = git_output(stage.repo.as_path(), &["fetch", "--prune", "--quiet", "origin"]);
assert!(fetched.status.success(), "the following fetch failed: {}", String::from_utf8_lossy(&fetched.stderr));
}
#[test]
fn locks_left_by_a_killed_fetch_are_cleared_and_the_next_fetch_works() {
let stage: Stage = killed::stage("stale-refs-killed-fetch", true);
let mut held: Held = killed::hold_fetch(&stage);
assert!(stage.lock_of(GONE_REF).exists() && stage.packed_lock().exists() && stage.packed_staging().exists(), "the fetch holds its locks");
assert_eq!(std::fs::metadata(stage.lock_of(GONE_REF)).expect("lock").len(), 0, "a deleted reference's lock is empty");
held.kill();
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert_eq!(outcome.cleared.len(), 2, "{outcome:?}");
assert_eq!(leftovers(&stage), Vec::<PathBuf>::new());
assert_eq!(verdict_of(&outcome, stage.packed_lock().as_path()), Some(Verdict::Stale(Proof::PidGone(i64::from(held.pid())))));
fetch_works(&stage);
stage.finish();
}
#[test]
fn locks_of_a_killed_fetch_with_no_pid_file_stay() {
let stage: Stage = killed::stage("stale-refs-no-pid-file", false);
let mut held: Held = killed::hold_fetch(&stage);
held.kill();
let before: Vec<PathBuf> = leftovers(&stage);
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert_eq!(leftovers(&stage), before);
assert!(matches!(verdict_of(&outcome, stage.lock_of(GONE_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
stage.finish();
}
#[test]
fn locks_of_a_running_git_stay_though_no_descriptor_holds_them() {
let stage: Stage = killed::stage("stale-refs-running", true);
let held: Held = killed::hold_transaction(&stage, &["delete refs/remotes/origin/gone", "delete refs/remotes/origin/main"]);
let before: Vec<PathBuf> = leftovers(&stage);
assert!(before.contains(&stage.packed_lock()) && before.contains(&stage.lock_of(GONE_REF)), "{before:?}");
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert_eq!(leftovers(&stage), before);
assert!(matches!(verdict_of(&outcome, stage.packed_lock().as_path()), Some(Verdict::Held(_))), "{outcome:?}");
drop(held);
stage.finish();
}
#[test]
fn locks_of_a_running_git_with_no_pid_file_stay() {
let stage: Stage = killed::stage("stale-refs-running-no-pid", false);
let held: Held = killed::hold_transaction(&stage, &["delete refs/remotes/origin/gone"]);
let before: Vec<PathBuf> = leftovers(&stage);
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert_eq!(leftovers(&stage), before);
drop(held);
stage.finish();
}
#[test]
fn a_lock_that_a_process_holds_open_stays_though_its_pid_file_names_a_gone_process() {
let stage: Stage = killed::stage("stale-refs-open", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let lock: PathBuf = stage.lock_of(PLANTED_REF);
let holder: Helper = Helper(Command::new("sh").arg("-c").arg("exec 3<>\"$1\"; exec sleep 120").arg("sh").arg(lock.as_os_str()).spawn().expect("start the holder"));
let holder_pid: i64 = holder.pid();
let descriptors: PathBuf = PathBuf::from(format!("/proc/{holder_pid}/fd"));
let started: std::time::Instant = std::time::Instant::now();
while !std::fs::read_dir(descriptors.as_path()).expect("fds").flatten().any(|entry| { return std::fs::read_link(entry.path()).is_ok_and(|target| { return target == lock; }); }) {
assert!(started.elapsed() < killed::HOLD_PATIENCE, "the holder opened the lock");
std::thread::sleep(Duration::from_millis(20));
}
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(lock.exists() && stage.pid_file_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, lock.as_path()), Some(Verdict::Held(_))), "{outcome:?}");
drop(holder);
stage.finish();
}
#[test]
fn a_lock_inside_the_margin_stays_and_goes_once_it_is_older() {
let stage: Stage = killed::stage("stale-refs-margin", true);
let mut held: Held = killed::hold_fetch(&stage);
held.kill();
let before: Vec<PathBuf> = leftovers(&stage);
let strict: Judge = Judge { margin: Duration::from_secs(3600), ..judge() };
let young: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &strict);
assert!(!young.cleared_any() && young.left.is_empty(), "a lock inside the margin is left without a word: {young:?}");
assert_eq!(leftovers(&stage), before);
let aged: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(aged.cleared_any(), "{aged:?}");
stage.finish();
}
#[test]
fn a_pid_file_born_before_the_lock_is_not_evidence() {
let stage: Stage = killed::stage("stale-refs-older-pid-file", true);
killed::write_file(stage.pid_file_of(PLANTED_REF).as_path(), &format!("pid {}\n", ended_pid()));
std::thread::sleep(Duration::from_millis(50));
killed::write_file(stage.lock_of(PLANTED_REF).as_path(), "");
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
stage.finish();
}
#[test]
fn a_process_number_now_held_by_another_program_proves_the_holder_gone() {
let stage: Stage = killed::stage("stale-refs-reused", true);
killed::write_file(stage.lock_of(PLANTED_REF).as_path(), "");
std::thread::sleep(Duration::from_millis(400));
let other: Helper = sleeper();
std::thread::sleep(Duration::from_millis(50));
killed::write_file(stage.pid_file_of(PLANTED_REF).as_path(), &format!("pid {}\n", other.pid()));
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert_eq!(outcome.cleared.len(), 1, "{outcome:?}");
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Stale(Proof::PidReused { .. }))), "{outcome:?}");
drop(other);
stage.finish();
}
#[test]
fn a_process_that_started_before_the_lock_is_the_holder_whatever_its_name() {
let stage: Stage = killed::stage("stale-refs-renamed-git", true);
let other: Helper = sleeper();
std::thread::sleep(Duration::from_millis(400));
plant_lock(&stage, PLANTED_REF, Some(other.pid()));
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Held(_))), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
drop(other);
stage.finish();
}
#[test]
fn a_running_git_named_by_the_pid_file_is_the_holder() {
let stage: Stage = killed::stage("stale-refs-running-git", true);
let idle: Held = killed::idle_git(&stage);
plant_lock(&stage, PLANTED_REF, Some(i64::from(idle.pid())));
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Held(_))), "{outcome:?}");
drop(idle);
stage.finish();
}
#[test]
fn a_lock_older_than_the_boot_is_left_and_said_to_be_unproven() {
let stage: Stage = killed::stage("stale-refs-boot", false);
plant_lock(&stage, PLANTED_REF, None);
let after: Judge = Judge { boot: Some(SystemTime::now() + A_DAY), ..judge() };
let before: Judge = Judge { boot: Some(SystemTime::now() - A_DAY), ..judge() };
let younger: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &before);
assert!(!younger.cleared_any(), "{younger:?}");
assert!(!matches!(verdict_of(&younger, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(text)) if text.contains("start of this boot")), "a lock younger than the boot is not said to look older: {younger:?}");
let older: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &after);
assert!(!older.cleared_any(), "{older:?}");
assert!(matches!(verdict_of(&older, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(text)) if text.contains("start of this boot") && text.contains("correction of the clock")), "{older:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(older.notice().contains("rm -- "), "the person is given the command that clears it: {}", older.notice());
stage.finish();
}
#[test]
fn the_boot_time_does_not_override_a_pid_file_that_names_a_running_git() {
let stage: Stage = killed::stage("stale-refs-boot-vs-pid", true);
let idle: Held = killed::idle_git(&stage);
plant_lock(&stage, PLANTED_REF, Some(i64::from(idle.pid())));
let after: Judge = Judge { boot: Some(SystemTime::now() + A_DAY), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &after);
assert!(!outcome.cleared_any(), "{outcome:?}");
drop(idle);
stage.finish();
}
#[test]
fn a_view_of_processes_that_may_be_partial_proves_nothing() {
let stage: Stage = killed::stage("stale-refs-partial-view", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let partial: Judge = Judge { pid_view_complete: false, ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &partial);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
stage.finish();
}
#[test]
fn a_file_system_off_the_local_list_proves_nothing() {
let stage: Stage = killed::stage("stale-refs-remote-fs", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let nowhere: Judge = Judge { local_file_systems: Vec::new(), boot: Some(SystemTime::now() + A_DAY), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &nowhere);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
stage.finish();
}
#[test]
fn an_unreadable_process_table_proves_nothing() {
let stage: Stage = killed::stage("stale-refs-no-proc", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let blind: Judge = Judge { proc_root: stage.root.join("no-such-proc"), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &blind);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
stage.finish();
}
#[test]
fn locks_of_other_protocols_are_never_candidates() {
let stage: Stage = killed::stage("stale-refs-other-locks", false);
let others: Vec<PathBuf> = vec![
stage.git_dir.join("index.lock"),
stage.git_dir.join("config.lock"),
stage.git_dir.join("shallow.lock"),
stage.git_dir.join("objects").join("pack").join("tmp.lock"),
stage.git_dir.join("refs").join("heads").join("alone~pid.lock"),
];
for path in &others {
killed::write_file(path.as_path(), "");
}
let after: Judge = Judge { boot: Some(SystemTime::now() + A_DAY), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &after);
assert!(!outcome.cleared_any() && outcome.left.is_empty(), "{outcome:?}");
for path in &others {
assert!(path.exists(), "{path:?}");
}
stage.finish();
}
#[test]
fn a_reference_with_shell_and_path_characters_is_cleared_literally() {
let stage: Stage = killed::stage("stale-refs-adversarial", true);
let name: &str = "refs/heads/it's$(touch${IFS}pwned);`id`&x|(y)";
let mut held: Held = killed::hold_transaction(&stage, &[format!("create {name} HEAD").as_str()]);
assert!(stage.lock_of(name).exists(), "Git holds the lock of the odd name");
held.kill();
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert_eq!(outcome.cleared.len(), 1, "{outcome:?}");
assert_eq!(leftovers(&stage), Vec::<PathBuf>::new());
assert!(!stage.repo.join("pwned").exists() && !Path::new("pwned").exists(), "nothing was run");
stage.finish();
}
#[test]
fn symbolic_links_are_neither_followed_nor_removed() {
let stage: Stage = killed::stage("stale-refs-symlinks", true);
let outside: PathBuf = stage.root.join("outside");
killed::write_file(outside.join("x.lock").as_path(), "");
killed::write_file(outside.join("target.lock").as_path(), "");
let heads: PathBuf = stage.git_dir.join("refs").join("heads");
std::os::unix::fs::symlink(outside.as_path(), heads.join("linked-directory")).expect("link a directory");
std::os::unix::fs::symlink(outside.join("target.lock"), heads.join("linked.lock")).expect("link a lock");
let after: Judge = Judge { boot: Some(SystemTime::now() + A_DAY), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &after);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(outside.join("x.lock").exists() && outside.join("target.lock").exists());
assert!(std::fs::symlink_metadata(heads.join("linked.lock")).is_ok());
remove(stage.root.as_path());
}
#[test]
fn a_lock_in_a_directory_that_cannot_be_written_is_left() {
use std::os::unix::fs::PermissionsExt as _;
let stage: Stage = killed::stage("stale-refs-unwritable", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let directory: PathBuf = stage.git_dir.join("refs").join("heads");
std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o555)).expect("make read-only");
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o755)).expect("make writable");
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
stage.finish();
}
#[test]
fn a_lock_taken_again_after_the_judgement_is_put_back() {
let stage: Stage = killed::stage("stale-refs-retaken", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let lock: PathBuf = stage.lock_of(PLANTED_REF);
let judged = crate::stale_ref_lock_judge::identity_of(lock.as_path()).expect("identity");
std::fs::remove_file(lock.as_path()).expect("the stale lock is removed by another wrapper");
killed::write_file(lock.as_path(), "live holder's content\n");
let mut outcome: Outcome = Outcome::default();
let removed = crate::stale_ref_lock_retire::remove_if_unchanged(stage.git_dir.as_path(), lock.as_path(), &judged, &mut outcome);
assert_eq!(removed, Ok(false));
assert_eq!(std::fs::read_to_string(lock.as_path()).expect("the live lock"), "live holder's content\n");
assert!(outcome.displaced.is_empty(), "{outcome:?}");
stage.finish();
}
#[test]
fn a_lock_that_differs_from_the_judged_one_when_taken_is_put_back_whole() {
let stage: Stage = killed::stage("stale-refs-taken-then-differs", true);
plant_lock(&stage, PLANTED_REF, None);
let lock: PathBuf = stage.lock_of(PLANTED_REF);
let judged = crate::stale_ref_lock_judge::identity_of(lock.as_path()).expect("identity");
std::fs::remove_file(lock.as_path()).expect("the stale lock is removed by another wrapper");
killed::write_file(lock.as_path(), "live holder's content\n");
let mut outcome: Outcome = Outcome::default();
let removed = crate::stale_ref_lock_retire::take_and_check(stage.git_dir.as_path(), lock.as_path(), &judged, &mut outcome);
assert_eq!(removed, Ok(false));
assert_eq!(std::fs::read_to_string(lock.as_path()).expect("the live lock"), "live holder's content\n");
assert!(outcome.displaced.is_empty(), "{outcome:?}");
let aside: Vec<String> = std::fs::read_dir(stage.git_dir.as_path())
.expect("read the Git directory")
.flatten()
.map(|entry| { return entry.file_name().to_string_lossy().into_owned(); })
.filter(|name| { return name.starts_with("cli-git-stale-lock"); })
.collect();
assert!(aside.is_empty(), "nothing is left under the private name: {aside:?}");
stage.finish();
}
#[test]
fn the_judged_lock_is_removed_when_taken() {
let stage: Stage = killed::stage("stale-refs-taken-judged", true);
plant_lock(&stage, PLANTED_REF, None);
let lock: PathBuf = stage.lock_of(PLANTED_REF);
let judged = crate::stale_ref_lock_judge::identity_of(lock.as_path()).expect("identity");
let mut outcome: Outcome = Outcome::default();
let removed = crate::stale_ref_lock_retire::take_and_check(stage.git_dir.as_path(), lock.as_path(), &judged, &mut outcome);
assert_eq!(removed, Ok(true));
assert!(!lock.exists());
stage.finish();
}
#[test]
fn a_pid_file_that_a_live_writer_made_after_the_judgement_is_not_removed_with_the_lock() {
let stage: Stage = killed::stage("stale-refs-pid-file-replaced", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let pid_file: PathBuf = stage.pid_file_of(PLANTED_REF);
let judged = crate::stale_ref_lock_judge::identity_of(pid_file.as_path()).expect("identity");
std::fs::remove_file(pid_file.as_path()).expect("the dead holder's pid file is removed by another wrapper");
killed::write_file(pid_file.as_path(), &format!("pid {}\n", std::process::id()));
let mut outcome: Outcome = Outcome::default();
let removed = crate::stale_ref_lock_retire::remove_if_unchanged(stage.git_dir.as_path(), pid_file.as_path(), &judged, &mut outcome);
assert_eq!(removed, Ok(false));
assert!(pid_file.exists());
stage.finish();
}
#[test]
fn the_roots_of_a_command_are_found_without_git() {
let stage: Stage = killed::stage("stale-refs-roots", false);
let mut command: Command = Command::new("git");
command.current_dir(stage.repo.as_path());
assert_eq!(crate::git_directory_roots::roots_of_command(&command), stage.roots());
stage.finish();
}
fn table_with_unreadable(stage: &Stage, command: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let table: PathBuf = stage.root.join("proc");
let descriptors: PathBuf = table.join("4242").join("fd");
std::fs::create_dir_all(descriptors.as_path()).expect("fake process");
std::fs::write(table.join("4242").join("comm"), format!("{command}\n")).expect("comm");
std::fs::set_permissions(descriptors.as_path(), std::fs::Permissions::from_mode(0o000)).expect("hide the descriptors");
return table;
}
fn reveal(table: &Path) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(table.join("4242").join("fd"), std::fs::Permissions::from_mode(0o700)).expect("reveal");
}
#[test]
fn an_unreadable_process_that_is_not_git_does_not_stop_a_proof() {
let stage: Stage = killed::stage("stale-refs-unreadable-service", true);
let gone: i64 = ended_pid();
plant_lock(&stage, PLANTED_REF, Some(gone));
let table: PathBuf = table_with_unreadable(&stage, "systemd");
let listed: Judge = Judge { proc_root: table.clone(), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &listed);
reveal(table.as_path());
assert_eq!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Stale(Proof::PidGone(gone))), "{outcome:?}");
stage.finish();
}
#[test]
fn an_unreadable_git_process_stops_a_proof() {
let stage: Stage = killed::stage("stale-refs-unreadable-git", true);
plant_lock(&stage, PLANTED_REF, Some(ended_pid()));
let table: PathBuf = table_with_unreadable(&stage, "git");
let listed: Judge = Judge { proc_root: table.clone(), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &listed);
reveal(table.as_path());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
stage.finish();
}
#[test]
fn a_staging_file_with_no_lock_beside_it_is_cleared() {
let stage: Stage = killed::stage("stale-refs-orphan-staging", true);
let mut held: Held = killed::hold_fetch(&stage);
held.kill();
std::fs::remove_file(stage.packed_lock()).expect("the lock is removed by hand");
std::fs::remove_file(stage.git_dir.join("packed-refs~pid.lock")).expect("its pid file too");
assert!(stage.packed_staging().exists());
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!stage.packed_staging().exists(), "{outcome:?}");
assert_eq!(verdict_of(&outcome, stage.packed_staging().as_path()), Some(Verdict::Stale(Proof::OrphanStaging)), "{outcome:?}");
assert_eq!(leftovers(&stage), Vec::<PathBuf>::new());
fetch_works(&stage);
stage.finish();
}
#[test]
fn a_staging_file_with_its_lock_beside_it_is_left_to_the_lock() {
let stage: Stage = killed::stage("stale-refs-live-staging", true);
let held: Held = killed::hold_transaction(&stage, &["delete refs/remotes/origin/gone"]);
assert!(stage.packed_staging().exists(), "the update holds its staging file");
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.packed_staging().exists());
drop(held);
stage.finish();
}
#[test]
fn a_staging_file_inside_the_margin_stays() {
let stage: Stage = killed::stage("stale-refs-young-staging", true);
killed::write_file(stage.packed_staging().as_path(), "# pack-refs with:\n");
let strict: Judge = Judge { margin: Duration::from_secs(3600), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &strict);
assert!(!outcome.cleared_any() && outcome.left.is_empty(), "{outcome:?}");
assert!(stage.packed_staging().exists());
stage.finish();
}
#[test]
fn a_git_that_started_after_the_lock_is_still_taken_for_the_holder() {
let stage: Stage = killed::stage("stale-refs-later-git", true);
killed::write_file(stage.lock_of(PLANTED_REF).as_path(), "");
std::thread::sleep(Duration::from_millis(400));
let later: Held = killed::idle_git(&stage);
std::thread::sleep(Duration::from_millis(50));
killed::write_file(stage.pid_file_of(PLANTED_REF).as_path(), &format!("pid {}\n", later.pid()));
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Held(_))), "{outcome:?}");
drop(later);
stage.finish();
}
#[test]
fn a_staging_file_that_a_process_holds_open_stays() {
let stage: Stage = killed::stage("stale-refs-open-staging", true);
killed::write_file(stage.packed_staging().as_path(), "# pack-refs with:\n");
let holder: Helper = Helper(Command::new("sh").arg("-c").arg("exec 3<>\"$1\"; exec sleep 120").arg("sh").arg(stage.packed_staging().as_os_str()).spawn().expect("start the holder"));
let descriptors: PathBuf = PathBuf::from(format!("/proc/{}/fd", holder.pid()));
let staging: PathBuf = stage.packed_staging();
let started: std::time::Instant = std::time::Instant::now();
while !std::fs::read_dir(descriptors.as_path()).expect("fds").flatten().any(|entry| { return std::fs::read_link(entry.path()).is_ok_and(|target| { return target == staging; }); }) {
assert!(started.elapsed() < killed::HOLD_PATIENCE, "the holder opened the staging file");
std::thread::sleep(Duration::from_millis(20));
}
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.packed_staging().exists());
assert!(matches!(verdict_of(&outcome, stage.packed_staging().as_path()), Some(Verdict::Held(_))), "{outcome:?}");
drop(holder);
stage.finish();
}
const A_DAY: Duration = Duration::from_secs(86_400);
#[test]
fn a_lock_that_a_clock_step_puts_before_the_boot_is_not_judged_stale() {
let stage: Stage = killed::stage("stale-refs-clock-step", false);
plant_lock(&stage, PLANTED_REF, None);
let stepped: Judge = Judge { boot: Some(SystemTime::now() + A_DAY), ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &stepped);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
stage.finish();
}
fn table_with_nested_process(stage: &Stage, outer: i64, inner: i64) -> PathBuf {
let table: PathBuf = stage.root.join("proc-nested");
let process: PathBuf = table.join(outer.to_string());
std::fs::create_dir_all(process.join("fd")).expect("fake process");
std::fs::write(process.join("comm"), "sh\n").expect("comm");
std::fs::write(process.join("status"), format!("Name:\tsh\nNSpid:\t{outer}\t{inner}\n")).expect("status");
return table;
}
#[test]
fn a_pid_number_that_a_nested_namespace_gave_its_writer_is_not_judged_gone() {
let stage: Stage = killed::stage("stale-refs-nested-number", true);
let inner: i64 = ended_pid();
plant_lock(&stage, PLANTED_REF, Some(inner));
let table: PathBuf = table_with_nested_process(&stage, inner + 1, inner);
let nested: Judge = Judge { proc_root: table, ..judge() };
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &nested);
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
stage.finish();
}
#[test]
#[cfg(target_os = "linux")]
fn a_writer_in_a_real_nested_namespace_is_not_judged_gone() {
let stage: Stage = killed::stage("stale-refs-real-namespace", true);
let Some(inside) = NestedProcess::start(FREE_LOCAL_NUMBER) else {
eprintln!("skipped: this host does not let an unprivileged process make a PID namespace");
stage.finish();
return;
};
assert!(!Path::new("/proc").join(inside.local.to_string()).exists(), "the number {} is free on this host", inside.local);
plant_lock(&stage, PLANTED_REF, Some(inside.local));
let outcome: Outcome = clear_stale_ref_locks(stage.roots().as_slice(), &judge());
assert!(!outcome.cleared_any(), "{outcome:?}");
assert!(stage.lock_of(PLANTED_REF).exists());
assert!(matches!(verdict_of(&outcome, stage.lock_of(PLANTED_REF).as_path()), Some(Verdict::Unproven(_))), "{outcome:?}");
drop(inside);
stage.finish();
}
const FREE_LOCAL_NUMBER: i64 = 3_000_000;
#[cfg(target_os = "linux")]
struct NestedProcess {
child: Child,
local: i64,
}
#[cfg(target_os = "linux")]
impl NestedProcess {
fn start(first: i64) -> Option<Self> {
use std::io::BufRead as _;
let script: String = format!("echo {first} > /proc/sys/kernel/ns_last_pid; sleep 120 & echo $!; read _; kill $!");
let mut child: Child = Command::new("unshare")
.args(["--user", "--map-root-user", "--pid", "--fork", "--mount-proc", "sh", "-c", script.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let output = child.stdout.take()?;
let mut line: String = String::new();
std::io::BufReader::new(output).read_line(&mut line).ok()?;
let local: i64 = line.trim().parse::<i64>().ok()?;
return Some(Self { child, local });
}
}
#[cfg(target_os = "linux")]
impl Drop for NestedProcess {
fn drop(&mut self) {
drop(self.child.stdin.take());
let _waited = self.child.wait();
}
}