monochromatic-git-policy-cli 0.5.16

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
Documentation
//! Controls for the scratch locations,
//!  each in its own disposable directory under the target directory.

use super::*;
use crate::test_support::{fixture, remove};
use std::os::unix::fs::PermissionsExt;

/// An environment of the given pairs.
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();
}

/// The permission bits of a path without following a final link.
fn mode_of(path: &Path) -> u32 {
    return std::fs::symlink_metadata(path).expect("metadata").permissions().mode() & 0o7777;
}

/// Set a file or directory's modification time to `back` before now.
fn age(path: &Path, back: Duration) {
    std::fs::File::open(path)
        .expect("open")
        .set_modified(SystemTime::now() - back)
        .expect("set modification time");
}

/// An absolute `XDG_STATE_HOME` wins; a relative one is ignored as the XDG rules say; `HOME` is the fallback.
#[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"))
    );
}

/// Without an absolute `XDG_STATE_HOME` and without a non-empty `HOME` there is no directory, and the sentence names
/// both variables and says the system temporary directory is not a fallback.
#[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);
}

/// The per-user scratch directory is made with every missing parent at mode `0700`, and a second preparation accepts it.
#[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());
}

/// A scratch path that is a link is refused and never followed, so no write can be redirected through it.
#[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());
}

/// An existing directory of this user with a looser mode is tightened to `0700`.
#[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());
}

/// The repository scratch directory is made inside the common directory at mode `0700` and accepted again.
#[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());
}

/// The sweep removes files, directories with contents and links older than the bound, never follows a link, and keeps
/// fresh entries and entries dated in the future.
#[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");
    // The link itself is fresh, so the first sweep keeps it: only its own time counts, never its target's.
    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"]);
    // Two days later the fresh file and the link are stale: both go, and the file the link points at stays.
    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());
}

/// A missing directory sweeps nothing and does not fail.
#[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());
}

/// Both preparations collect what a killed process left, so stale files cannot accumulate without bound.
#[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());
}

/// An anonymous scratch file is read and written, has no name in the per-user scratch directory, and without a home
/// the failure names the variables instead of falling back to the system temporary directory.
#[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());
}

/// The staleness bound outlasts every wait the wrapper runs: a live process must never lose its file.
#[test]
fn the_staleness_bound_outlasts_the_longest_wait() {
    for class in [crate::child_class::ChildClass::Plumbing, crate::child_class::ChildClass::Object] {
        assert!(SCRATCH_STALE_AFTER > class.default_limit(), "{class:?}");
    }
    assert!(SCRATCH_STALE_AFTER > crate::wait_limit::DEFAULT_LIVE_WAIT);
}

/// An entry exactly as old as the bound is kept; one a second older goes.
#[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());
}

/// A creation failure other than "already exists" is returned as it is, not hidden behind the later privacy check.
#[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");
    // A process that ignores permission bits (root) cannot be refused, so only a refusing process proves the error.
    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());
}

/// A directory with no group or other access is accepted as it is: only a looser mode is rewritten, so its sticky bit
/// stays, which a mode rewrite would clear.
#[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());
}