use super::{
AUDIT_FILE, AuditOutcome, AuditRecord, ROTATE_AT_BYTES, append_line, audit_line, audit_place, move_aside_if_still_named,
parent_process, record_audit,
};
use crate::command_test_support::{fixture, os_arguments, remove};
use crate::rule_linked_worktree::GuardedCommand;
use crate::worktree_identity::WorktreeIdentity;
use std::path::{Path, PathBuf};
fn main_identity(root: &Path) -> WorktreeIdentity {
return WorktreeIdentity::MainWorktree {
common_dir: root.join(".git"),
git_dir: root.join(".git"),
worktree_root: root.to_path_buf(),
};
}
#[test]
fn the_line_states_who_ran_what_where_and_what_was_decided() {
let place: (PathBuf, &'static str) = (PathBuf::from("/r/.git"), "main");
let arguments = os_arguments(&["-C", "/r", "restore", "--", "a.txt"]);
let line: String = audit_line(&AuditRecord {
time_millis: 1_700_000_000_123,
process: 42,
parent: 7,
form: GuardedCommand::RestoreWorktree,
outcome: AuditOutcome::Rejected,
place: &place,
git_dir: Path::new("/r/.git/worktrees/w"),
directory: Path::new("/r/sub"),
arguments: arguments.as_slice(),
dirty: Some(true),
copies: &[],
});
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1);
let value: serde_json::Value = serde_json::from_str(line.trim_end()).expect("JSON");
assert_eq!(value["schemaVersion"], 1);
assert_eq!(value["timeMillis"], "1700000000123");
assert_eq!(value["time"], "2023-11-14T22:13:20.123Z", "the time in the form every other record uses");
assert_eq!(value["release"], crate::service_protocol::RELEASE);
assert_eq!(value["gitDir"], "/r/.git/worktrees/w", "the worktree's own Git directory, which the kind word does not name");
assert_eq!(value["level"], "warn", "a refusal stands out");
assert_eq!(value["pid"], 42);
assert_eq!(value["parentPid"], 7);
assert_eq!(value["worktree"], "main");
assert_eq!(value["form"], "RestoreWorktree");
assert_eq!(value["outcome"], "rejected");
assert_eq!(value["dirty"], true);
assert_eq!(value["cwd"], "/r/sub");
assert_eq!(value["arguments"], serde_json::Value::from(vec!["-C", "/r", "restore", "--", "a.txt"]));
let unasked: String = audit_line(&AuditRecord {
time_millis: 1,
process: 1,
parent: 1,
form: GuardedCommand::Stash,
outcome: AuditOutcome::Hatch,
place: &place,
git_dir: Path::new("/r/.git/worktrees/w"),
directory: Path::new("/r"),
arguments: &[],
dirty: None,
copies: &[],
});
let unasked_value: serde_json::Value = serde_json::from_str(unasked.trim_end()).expect("JSON");
assert_eq!(unasked_value["dirty"], serde_json::Value::Null);
assert_eq!(unasked_value["outcome"], "hatch");
assert_eq!(unasked_value["level"], "info");
}
#[test]
fn each_outcome_has_its_own_word() {
let place: (PathBuf, &'static str) = (PathBuf::from("/r/.git"), "linked");
let mut words: Vec<String> = Vec::<String>::new();
for outcome in [
AuditOutcome::Rejected,
AuditOutcome::Allowed,
AuditOutcome::Exempt,
AuditOutcome::Hatch,
] {
let line: String = audit_line(&AuditRecord {
time_millis: 1,
process: 1,
parent: 1,
form: GuardedCommand::Clean,
outcome,
place: &place,
git_dir: Path::new("/r/.git/worktrees/w"),
directory: Path::new("/r"),
arguments: &[],
dirty: None,
copies: &[],
});
let value: serde_json::Value = serde_json::from_str(line.trim_end()).expect("JSON");
words.push(value["outcome"].as_str().expect("word").to_owned());
}
assert_eq!(words, vec!["rejected", "allowed", "exempt", "hatch"]);
}
#[test]
fn the_place_is_the_common_directory_with_the_worktree_kind() {
let root = Path::new("/r");
assert_eq!(audit_place(&WorktreeIdentity::OutsideWorktree), None);
assert_eq!(
audit_place(&main_identity(root)),
Some((PathBuf::from("/r/.git"), "main"))
);
assert_eq!(
audit_place(&WorktreeIdentity::LinkedWorktree {
common_dir: PathBuf::from("/r/.git"),
git_dir: PathBuf::from("/r/.git/worktrees/w"),
worktree_root: PathBuf::from("/w"),
}),
Some((PathBuf::from("/r/.git"), "linked"))
);
assert_eq!(
audit_place(&WorktreeIdentity::BareRepository {
common_dir: PathBuf::from("/r.git"),
git_dir: PathBuf::from("/r.git"),
}),
Some((PathBuf::from("/r.git"), "bare"))
);
}
#[test]
fn records_append_in_order_and_the_directory_is_never_created() {
let root: PathBuf = fixture("audit-append");
std::fs::create_dir(root.join(".git")).expect("git directory");
let identity: WorktreeIdentity = main_identity(root.as_path());
let arguments = os_arguments(&["restore", "a.txt"]);
record_audit(&identity, GuardedCommand::RestoreWorktree, AuditOutcome::Rejected, arguments.as_slice(), Some(false));
record_audit(&identity, GuardedCommand::Stash, AuditOutcome::Hatch, arguments.as_slice(), None);
let text: String = std::fs::read_to_string(root.join(".git").join(AUDIT_FILE)).expect("audit file");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"outcome\":\"rejected\""));
assert!(lines[1].contains("\"outcome\":\"hatch\""));
let missing: PathBuf = root.join("absent");
record_audit(
&main_identity(missing.as_path()),
GuardedCommand::Clean,
AuditOutcome::Allowed,
arguments.as_slice(),
Some(false),
);
assert!(!missing.exists());
remove(root.as_path());
}
#[test]
fn a_full_file_moves_aside() {
let root: PathBuf = fixture("audit-rotate");
let target: PathBuf = root.join(AUDIT_FILE);
let mut filler: Vec<u8> = vec![b'x'; ROTATE_AT_BYTES as usize - 1];
filler[ROTATE_AT_BYTES as usize - 2] = b'\n';
std::fs::write(&target, filler).expect("nearly full");
append_line(target.as_path(), b"next\n").expect("append");
assert!(!root.join(format!("{AUDIT_FILE}.1")).exists());
assert_eq!(std::fs::metadata(&target).expect("metadata").len(), ROTATE_AT_BYTES + 4);
append_line(target.as_path(), b"after\n").expect("append past the limit");
assert_eq!(std::fs::read(&target).expect("new file"), b"after\n");
assert_eq!(
std::fs::metadata(root.join(format!("{AUDIT_FILE}.1"))).expect("aside").len(),
ROTATE_AT_BYTES + 4
);
remove(root.as_path());
}
#[test]
fn the_size_limit_is_positive() {
const { assert!(ROTATE_AT_BYTES > 0) };
}
#[cfg(unix)]
#[test]
fn the_parent_is_the_process_that_started_this_one() {
let parent: u32 = parent_process();
assert_eq!(parent, std::os::unix::process::parent_id());
assert!(parent > 1, "parent {parent}");
}
#[test]
fn a_torn_last_line_does_not_swallow_the_next_record() {
let root: PathBuf = fixture("audit-torn");
let target: PathBuf = root.join(AUDIT_FILE);
std::fs::write(&target, b"{\"ok\":1}\n{\"torn\":").expect("a torn tail");
append_line(target.as_path(), b"{\"next\":2}\n").expect("append");
append_line(target.as_path(), b"{\"last\":3}\n").expect("append");
let text: String = std::fs::read_to_string(&target).expect("file");
let parsed: Vec<bool> = text.lines().map(|line| { return serde_json::from_str::<serde_json::Value>(line).is_ok(); }).collect();
assert_eq!(parsed, vec![true, false, true, true], "{text}");
assert_eq!(text.lines().last(), Some("{\"last\":3}"), "a whole file adds nothing extra");
remove(root.as_path());
}
#[test]
fn a_second_rotation_does_not_replace_the_history_with_the_new_file() {
let root: PathBuf = fixture("audit-rotate-race");
let target: PathBuf = root.join(AUDIT_FILE);
let aside: PathBuf = root.join(format!("{AUDIT_FILE}.1"));
std::fs::write(&target, b"the full history\n").expect("the full file");
let seen_full: std::fs::File = std::fs::OpenOptions::new().read(true).append(true).open(&target).expect("open");
std::fs::rename(&target, &aside).expect("the other writer's rotation");
std::fs::write(&target, b"{\"new\":1}\n").expect("the new file");
move_aside_if_still_named(target.as_path(), &seen_full).expect("no error");
assert_eq!(std::fs::read(&aside).expect("aside"), b"the full history\n", "the history is still there");
assert_eq!(std::fs::read(&target).expect("target"), b"{\"new\":1}\n", "the new file stays");
let current: std::fs::File = std::fs::OpenOptions::new().read(true).append(true).open(&target).expect("open");
move_aside_if_still_named(target.as_path(), ¤t).expect("moved");
assert!(!target.exists());
assert_eq!(std::fs::read(&aside).expect("aside"), b"{\"new\":1}\n");
remove(root.as_path());
}
#[test]
fn concurrent_appenders_lose_no_line_and_tear_none() {
let root: PathBuf = fixture("audit-concurrent");
let target: PathBuf = root.join(AUDIT_FILE);
let writers: usize = 6;
let each: usize = 60;
std::thread::scope(|scope| {
for writer in 0..writers {
let path: &Path = target.as_path();
scope.spawn(move || {
for number in 0..each {
let line: String = format!("{{\"writer\":{writer},\"number\":{number},\"pad\":\"{}\"}}\n", "p".repeat(200));
append_line(path, line.as_bytes()).expect("append");
}
});
}
});
let text: String = std::fs::read_to_string(&target).expect("file");
let mut seen: std::collections::BTreeSet<(u64, u64)> = std::collections::BTreeSet::new();
for line in text.lines().filter(|line| { return !line.is_empty(); }) {
let value: serde_json::Value = serde_json::from_str(line).unwrap_or_else(|error| { panic!("a torn line {line:?}: {error}") });
seen.insert((value["writer"].as_u64().expect("writer"), value["number"].as_u64().expect("number")));
}
assert_eq!(seen.len(), writers * each);
remove(root.as_path());
}
#[test]
fn a_file_that_cannot_be_read_is_still_appended_to() {
use std::os::unix::fs::PermissionsExt;
let root: PathBuf = fixture("audit-write-only");
let target: PathBuf = root.join(AUDIT_FILE);
std::fs::write(&target, b"{\"first\":1}\n").expect("file");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o200)).expect("write only");
append_line(target.as_path(), b"{\"second\":2}\n").expect("appended without read permission");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).expect("readable again");
assert_eq!(std::fs::read_to_string(&target).expect("file"), "{\"first\":1}\n{\"second\":2}\n");
remove(root.as_path());
}
#[test]
fn a_full_disk_is_an_error_not_a_second_write() {
let error = append_line(Path::new("/dev/full"), b"{\"x\":1}\n").expect_err("the device refuses every write");
assert_eq!(error.raw_os_error(), Some(libc::ENOSPC), "{error:?}");
}