use super::*;
use crate::test_support::{fixture, remove};
use std::os::unix::fs::PermissionsExt;
fn environment(pairs: &[(&str, &Path)]) -> Vec<(OsString, OsString)> {
return pairs
.iter()
.map(|(name, value)| { return (OsString::from(name), value.as_os_str().to_os_string()); })
.collect();
}
fn mode_of(path: &Path) -> u32 {
return std::fs::symlink_metadata(path).expect("metadata").permissions().mode() & 0o7777;
}
fn age(path: &Path, back: Duration) {
std::fs::File::open(path)
.expect("open")
.set_modified(SystemTime::now() - back)
.expect("set modification time");
}
#[test]
fn the_state_directory_follows_the_xdg_rules() {
assert_eq!(
state_directory(&environment(&[("XDG_STATE_HOME", Path::new("/state")), ("HOME", Path::new("/home/u"))])),
Some(PathBuf::from("/state/cli-git"))
);
assert_eq!(
state_directory(&environment(&[("XDG_STATE_HOME", Path::new("relative")), ("HOME", Path::new("/home/u"))])),
Some(PathBuf::from("/home/u/.local/state/cli-git"))
);
assert_eq!(
state_directory(&environment(&[("HOME", Path::new("/home/u"))])),
Some(PathBuf::from("/home/u/.local/state/cli-git"))
);
}
#[test]
fn a_missing_home_is_named_and_never_falls_back_to_the_system_directory() {
assert_eq!(state_directory(&[]), None);
assert_eq!(state_directory(&environment(&[("HOME", Path::new(""))])), None);
let reason: String = user_scratch_path(&[]).expect_err("no home");
assert!(reason.contains("XDG_STATE_HOME") && reason.contains("HOME"), "{reason}");
assert!(reason.contains("never in the system temporary directory"), "{reason}");
assert_eq!(prepare_user_scratch(&[]).expect_err("no home"), reason);
}
#[test]
fn the_user_scratch_directory_is_created_private_with_its_parents() {
let root: PathBuf = fixture("scratch-user-create");
let home: PathBuf = root.join("home");
std::fs::create_dir(&home).expect("home");
let env: Vec<(OsString, OsString)> = environment(&[("HOME", home.as_path())]);
let scratch: PathBuf = prepare_user_scratch(env.as_slice()).expect("first preparation");
assert_eq!(scratch, home.join(".local/state/cli-git/scratch"));
for directory in [".local", ".local/state", ".local/state/cli-git", ".local/state/cli-git/scratch"] {
assert_eq!(mode_of(home.join(directory).as_path()), 0o700, "{directory}");
}
assert_eq!(prepare_user_scratch(env.as_slice()).expect("second preparation"), scratch);
remove(root.as_path());
}
#[test]
fn a_linked_scratch_directory_is_refused() {
let root: PathBuf = fixture("scratch-user-link");
let home: PathBuf = root.join("home");
let elsewhere: PathBuf = root.join("elsewhere");
std::fs::create_dir_all(home.join(".local/state/cli-git")).expect("state");
std::fs::create_dir(&elsewhere).expect("elsewhere");
std::os::unix::fs::symlink(&elsewhere, home.join(".local/state/cli-git/scratch")).expect("plant link");
let env: Vec<(OsString, OsString)> = environment(&[("HOME", home.as_path())]);
let reason: String = prepare_user_scratch(env.as_slice()).expect_err("a link is refused");
assert!(reason.contains("not a directory"), "{reason}");
let repository_link: PathBuf = root.join("common");
std::fs::create_dir(&repository_link).expect("common");
std::os::unix::fs::symlink(&elsewhere, repository_link.join(REPOSITORY_SCRATCH_NAME)).expect("plant link");
assert!(prepare_repository_scratch(repository_link.as_path()).is_err());
assert_eq!(std::fs::read_dir(&elsewhere).expect("elsewhere").count(), 0);
remove(root.as_path());
}
#[test]
fn a_loose_scratch_directory_of_this_user_is_tightened() {
let root: PathBuf = fixture("scratch-user-loose");
let home: PathBuf = root.join("home");
let scratch: PathBuf = home.join(".local/state/cli-git/scratch");
std::fs::create_dir_all(&scratch).expect("scratch");
std::fs::set_permissions(&scratch, std::fs::Permissions::from_mode(0o755)).expect("loosen");
let env: Vec<(OsString, OsString)> = environment(&[("HOME", home.as_path())]);
prepare_user_scratch(env.as_slice()).expect("preparation");
assert_eq!(mode_of(scratch.as_path()), 0o700);
remove(root.as_path());
}
#[test]
fn the_repository_scratch_directory_is_created_private_beside_the_common_directory_entries() {
let root: PathBuf = fixture("scratch-repository-create");
let first: PathBuf = prepare_repository_scratch(root.as_path()).expect("first preparation");
assert_eq!(first, root.join("cli-git-scratch"));
assert_eq!(mode_of(first.as_path()), 0o700);
assert_eq!(prepare_repository_scratch(root.as_path()).expect("second preparation"), first);
let missing: PathBuf = root.join("no-such-common-directory");
assert!(prepare_repository_scratch(missing.as_path()).is_err());
remove(root.as_path());
}
#[test]
fn the_sweep_removes_only_stale_entries_and_never_follows_links() {
let root: PathBuf = fixture("scratch-sweep");
let scratch: PathBuf = root.join("scratch");
std::fs::create_dir(&scratch).expect("scratch");
let outside: PathBuf = root.join("outside.txt");
std::fs::write(&outside, b"outside").expect("outside file");
age(outside.as_path(), SCRATCH_STALE_AFTER * 3);
std::fs::write(scratch.join("cli-git-old-file"), b"old").expect("old file");
std::fs::create_dir(scratch.join("cli-git-old-directory")).expect("old directory");
std::fs::write(scratch.join("cli-git-old-directory/inner"), b"inner").expect("inner");
std::fs::write(scratch.join("cli-git-fresh"), b"fresh").expect("fresh file");
std::fs::write(scratch.join("cli-git-future"), b"future").expect("future file");
std::os::unix::fs::symlink(&outside, scratch.join("cli-git-old-link")).expect("link");
let long_ago: SystemTime = SystemTime::now() - SCRATCH_STALE_AFTER * 2;
std::fs::File::open(scratch.join("cli-git-old-file")).expect("open").set_modified(long_ago).expect("age");
std::fs::File::open(scratch.join("cli-git-old-directory/inner")).expect("open").set_modified(long_ago).expect("age");
std::fs::File::open(scratch.join("cli-git-old-directory")).expect("open").set_modified(long_ago).expect("age");
std::fs::File::open(scratch.join("cli-git-future"))
.expect("open")
.set_modified(SystemTime::now() + SCRATCH_STALE_AFTER * 2)
.expect("future");
let removed: usize = sweep_stale(scratch.as_path(), SystemTime::now(), SCRATCH_STALE_AFTER);
assert_eq!(removed, 2);
let mut left: Vec<String> = std::fs::read_dir(&scratch)
.expect("list")
.map(|entry| { return entry.expect("entry").file_name().to_string_lossy().into_owned(); })
.collect();
left.sort();
assert_eq!(left, ["cli-git-fresh", "cli-git-future", "cli-git-old-link"]);
let later: SystemTime = SystemTime::now() + SCRATCH_STALE_AFTER * 2;
assert_eq!(sweep_stale(scratch.as_path(), later, SCRATCH_STALE_AFTER), 2);
assert!(std::fs::symlink_metadata(scratch.join("cli-git-old-link")).is_err(), "the link is gone");
assert_eq!(std::fs::read(&outside).expect("target"), b"outside");
remove(root.as_path());
}
#[test]
fn sweeping_a_missing_directory_removes_nothing() {
let root: PathBuf = fixture("scratch-sweep-missing");
assert_eq!(sweep_stale(root.join("missing").as_path(), SystemTime::now(), SCRATCH_STALE_AFTER), 0);
remove(root.as_path());
}
#[test]
fn preparing_a_directory_collects_what_a_killed_process_left() {
let root: PathBuf = fixture("scratch-prepare-sweep");
let home: PathBuf = root.join("home");
let scratch: PathBuf = home.join(".local/state/cli-git/scratch");
std::fs::create_dir_all(&scratch).expect("scratch");
std::fs::write(scratch.join("cli-git-markdown-stale.jsonc"), b"[]").expect("stale");
age(scratch.join("cli-git-markdown-stale.jsonc").as_path(), SCRATCH_STALE_AFTER * 2);
std::fs::write(scratch.join("cli-git-markdown-live.jsonc"), b"[]").expect("live");
let env: Vec<(OsString, OsString)> = environment(&[("HOME", home.as_path())]);
prepare_user_scratch(env.as_slice()).expect("preparation");
assert!(!scratch.join("cli-git-markdown-stale.jsonc").exists());
assert!(scratch.join("cli-git-markdown-live.jsonc").exists());
let common: PathBuf = root.join("common");
std::fs::create_dir_all(common.join(REPOSITORY_SCRATCH_NAME)).expect("repository scratch");
std::fs::write(common.join("cli-git-scratch/cli-git-input-stale"), b"x").expect("stale");
age(common.join("cli-git-scratch/cli-git-input-stale").as_path(), SCRATCH_STALE_AFTER * 2);
prepare_repository_scratch(common.as_path()).expect("preparation");
assert!(!common.join("cli-git-scratch/cli-git-input-stale").exists());
remove(root.as_path());
}
#[test]
fn a_scratch_file_has_no_name_and_needs_a_home() {
use std::io::{Read, Seek, SeekFrom, Write};
let root: PathBuf = fixture("scratch-file");
let home: PathBuf = root.join("home");
std::fs::create_dir(&home).expect("home");
let env: Vec<(OsString, OsString)> = environment(&[("HOME", home.as_path())]);
let mut file: std::fs::File = scratch_file(env.as_slice()).expect("scratch file");
file.write_all(b"bytes").expect("write");
file.seek(SeekFrom::Start(0)).expect("seek");
let mut read: Vec<u8> = Vec::new();
file.read_to_end(&mut read).expect("read");
assert_eq!(read, b"bytes");
let scratch: PathBuf = home.join(".local/state/cli-git/scratch");
assert_eq!(std::fs::read_dir(&scratch).expect("scratch directory").count(), 0, "the file has no name");
let error: std::io::Error = scratch_file(&[]).expect_err("no home");
assert!(error.to_string().contains("never in the system temporary directory"), "{error}");
remove(root.as_path());
}
#[test]
fn the_staleness_bound_is_exactly_one_day() {
assert_eq!(SCRATCH_STALE_AFTER, Duration::from_secs(86_400));
}
#[test]
fn the_sweep_keeps_an_entry_exactly_at_the_bound_and_removes_one_past_it() {
let root: PathBuf = fixture("scratch-sweep-boundary");
let scratch: PathBuf = root.join("scratch");
std::fs::create_dir(&scratch).expect("scratch");
let entry: PathBuf = scratch.join("cli-git-boundary");
std::fs::write(&entry, b"x").expect("entry");
let modified: SystemTime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
std::fs::File::open(&entry).expect("open").set_modified(modified).expect("set modification time");
let at_bound: SystemTime = modified + SCRATCH_STALE_AFTER;
assert_eq!(sweep_stale(scratch.as_path(), at_bound, SCRATCH_STALE_AFTER), 0);
assert!(entry.exists(), "an entry exactly at the bound is kept");
let past_bound: SystemTime = at_bound + Duration::from_secs(1);
assert_eq!(sweep_stale(scratch.as_path(), past_bound, SCRATCH_STALE_AFTER), 1);
assert!(!entry.exists(), "an entry past the bound is removed");
remove(root.as_path());
}
#[test]
fn a_refused_creation_surfaces_its_own_error() {
let root: PathBuf = fixture("scratch-repository-refused");
let common: PathBuf = root.join("common");
std::fs::create_dir(&common).expect("common");
std::fs::set_permissions(&common, std::fs::Permissions::from_mode(0o500)).expect("seal");
let writable_anyway: bool = std::fs::create_dir(common.join("probe")).is_ok();
let outcome: std::io::Result<PathBuf> = prepare_repository_scratch(common.as_path());
std::fs::set_permissions(&common, std::fs::Permissions::from_mode(0o700)).expect("unseal");
if !writable_anyway {
let error: std::io::Error = outcome.expect_err("creation is refused");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied, "{error}");
}
remove(root.as_path());
}
#[test]
fn a_private_directory_is_left_untouched() {
let root: PathBuf = fixture("scratch-private-untouched");
let common: PathBuf = root.join("common");
let scratch: PathBuf = common.join(REPOSITORY_SCRATCH_NAME);
std::fs::create_dir_all(&scratch).expect("scratch");
std::fs::set_permissions(&scratch, std::fs::Permissions::from_mode(0o1700)).expect("sticky");
assert_eq!(mode_of(scratch.as_path()), 0o1700, "the fixture is sticky");
prepare_repository_scratch(common.as_path()).expect("preparation");
assert_eq!(mode_of(scratch.as_path()), 0o1700);
remove(root.as_path());
}