use super::commit::{remove_settled, wrapped_commit};
use super::fault::{staged_repository, verify_recovery};
use super::support::{Fixture, bounded, direct, fixture, git, repository, wrapper_first_path};
use git_policy_cli::hook_dispatch_plan::{ConfigParameters, HookDispatchPlan, PLAN_FILENAME, encode_hook_dispatch_plan};
use git_policy_cli::transaction_registry::TRANSACTION_ROOT_NAME;
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
#[derive(Clone, Copy, Debug)]
enum StreamFault {
Closed,
BrokenPipe,
FullDevice,
}
const OUTPUT_FAULTS: [StreamFault; 3] = [StreamFault::Closed, StreamFault::BrokenPipe, StreamFault::FullDevice];
fn broken_pipe() -> OwnedFd {
let mut ends: [libc::c_int; 2] = [0, 0];
let made: libc::c_int = unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) };
assert_eq!(made, 0, "pipe2 failed: {}", std::io::Error::last_os_error());
let (read_end, write_end): (OwnedFd, OwnedFd) = unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
drop(read_end);
return write_end;
}
fn faulty_output(fault: StreamFault) -> Stdio {
match fault {
StreamFault::Closed => return Stdio::null(),
StreamFault::BrokenPipe => return Stdio::from(broken_pipe()),
StreamFault::FullDevice => {
return Stdio::from(std::fs::OpenOptions::new().write(true).open("/dev/full").expect("/dev/full"));
}
}
}
fn close_standard_streams(command: &mut Command) {
unsafe {
command.pre_exec(|| {
libc::close(0);
libc::close(1);
libc::close(2);
return Ok(());
});
}
}
fn apply(command: &mut Command, fault: StreamFault) {
match fault {
StreamFault::Closed => {
command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
close_standard_streams(command);
}
StreamFault::BrokenPipe | StreamFault::FullDevice => {
command.stdin(Stdio::null()).stdout(faulty_output(fault)).stderr(faulty_output(fault));
}
}
}
const SUMMARY_COMMIT: [&str; 4] = ["commit", "--message=x", "--", "a.txt"];
fn commit_with_faulty_streams(name: &str, fault: StreamFault) {
let fixture: Fixture = fixture(name);
let repo: PathBuf = staged_repository(&fixture, "repo");
let mut command: Command = wrapped_commit(&fixture, repo.as_path(), &SUMMARY_COMMIT);
apply(&mut command, fault);
let finished: ExitStatus = command.status().expect("the commit starts");
assert_ne!(finished.signal(), Some(9), "{fault:?}: the commit hit its time bound");
assert_ne!(finished.code(), Some(101), "{fault:?}: the wrapper panicked");
let verdict: Result<(), String> = verify_recovery(&fixture, repo.as_path(), name, format!("{finished:?}").as_str());
assert_eq!(verdict, Ok(()), "{fault:?}");
remove_settled(&fixture);
}
#[test]
fn a_commit_with_closed_standard_streams_lands() {
commit_with_faulty_streams("stream-closed", StreamFault::Closed);
}
#[test]
fn a_commit_with_broken_output_pipes_lands() {
commit_with_faulty_streams("stream-pipe", StreamFault::BrokenPipe);
}
#[test]
fn a_commit_with_full_output_devices_lands() {
commit_with_faulty_streams("stream-full", StreamFault::FullDevice);
}
#[test]
fn a_commit_with_faulty_streams_ends_as_git_does() {
let fixture: Fixture = fixture("stream-exit");
for fault in OUTPUT_FAULTS {
let wrapped_repo: PathBuf = staged_repository(&fixture, &format!("wrapped-{fault:?}"));
let mut through_wrapper: Command = wrapped_commit(&fixture, wrapped_repo.as_path(), &SUMMARY_COMMIT);
apply(&mut through_wrapper, fault);
let wrapped: ExitStatus = through_wrapper.status().expect("the wrapped commit starts");
let direct_repo: PathBuf = staged_repository(&fixture, &format!("direct-{fault:?}"));
let mut straight: Command = direct(&fixture);
straight.current_dir(direct_repo.as_path()).args(SUMMARY_COMMIT);
apply(&mut straight, fault);
let oracle: ExitStatus = straight.status().expect("the direct commit starts");
assert_eq!(
shell_status(wrapped),
shell_status(oracle),
"{fault:?}: the wrapped commit ended with {wrapped:?}, Git started directly with {oracle:?}"
);
}
remove_settled(&fixture);
}
fn shell_status(status: ExitStatus) -> i32 {
return status.code().unwrap_or_else(|| { return 128 + status.signal().unwrap_or(0); });
}
fn repository_with_a_big_file(fixture: &Fixture) -> PathBuf {
let repo: PathBuf = repository(fixture, std::ffi::OsStr::new("big"));
std::fs::write(repo.join("big.txt"), "x".repeat(1 << 20)).expect("big file");
git(fixture, repo.as_path(), &["add", "--", "big.txt"]);
git(fixture, repo.as_path(), &["commit", "--quiet", "--message=big"]);
return repo;
}
fn show_both_ways(fixture: &Fixture, repo: &Path, fault: StreamFault) -> (ExitStatus, ExitStatus) {
let mut through_wrapper: Command = bounded(fixture, fixture.root.join("bin/git").as_path(), wrapper_first_path(fixture).as_os_str());
through_wrapper.current_dir(repo).args(["show", "HEAD:big.txt"]);
apply(&mut through_wrapper, fault);
let mut straight: Command = direct(fixture);
straight.current_dir(repo).args(["show", "HEAD:big.txt"]);
apply(&mut straight, fault);
return (
through_wrapper.status().expect("the wrapped command starts"),
straight.status().expect("the direct command starts"),
);
}
#[test]
fn a_forwarded_command_into_a_broken_pipe_ends_as_git_does() {
let fixture: Fixture = fixture("stream-forward-pipe");
let repo: PathBuf = repository_with_a_big_file(&fixture);
let (wrapped, straight) = show_both_ways(&fixture, repo.as_path(), StreamFault::BrokenPipe);
assert_eq!(straight.signal(), Some(libc::SIGPIPE), "the control: Git started directly ends by SIGPIPE");
assert_eq!(wrapped, straight);
remove_settled(&fixture);
}
#[test]
fn a_forwarded_command_into_a_full_device_ends_as_git_does() {
let fixture: Fixture = fixture("stream-forward-full");
let repo: PathBuf = repository_with_a_big_file(&fixture);
let (wrapped, straight) = show_both_ways(&fixture, repo.as_path(), StreamFault::FullDevice);
assert_ne!(straight.code(), Some(0), "the control: Git started directly reports a write error");
assert_eq!(wrapped, straight);
remove_settled(&fixture);
}
#[test]
fn a_forwarded_command_with_closed_streams_ends_as_git_does() {
let fixture: Fixture = fixture("stream-forward-closed");
let repo: PathBuf = repository_with_a_big_file(&fixture);
let (wrapped, straight) = show_both_ways(&fixture, repo.as_path(), StreamFault::Closed);
assert_eq!(wrapped, straight);
remove_settled(&fixture);
}
#[test]
fn a_hook_entry_that_cannot_report_still_exits_with_its_status() {
let fixture: Fixture = fixture("stream-hook-entry");
let hooks: PathBuf = fixture.root.join(TRANSACTION_ROOT_NAME).join("0b6c2c1e-6f5b-4d0e-9a55-3f5d8e2f6a10").join("hooks");
std::fs::create_dir_all(&hooks).expect("hooks directory");
let plan: HookDispatchPlan = HookDispatchPlan {
git_path: String::from("/nonexistent/git"),
disabled_events: Vec::new(),
config_parameters: ConfigParameters::Absent,
worktree_root: String::from("/repo"),
lease: String::from("lease"),
hook_lock_path: String::from("/nonexistent/lock"),
skip_hook_lock: true,
named_hooks_possible: true,
};
std::fs::write(hooks.join(PLAN_FILENAME), format!("{}\n", encode_hook_dispatch_plan(&plan))).expect("plan");
let entry: PathBuf = hooks.join("pre-commit");
std::os::unix::fs::symlink(&fixture.wrapper, &entry).expect("hook entry link");
let readable: std::process::Output = Command::new(&entry)
.env_clear()
.stdin(Stdio::null())
.output()
.expect("the hook entry starts");
assert_eq!(readable.status.code(), Some(1), "the control: a reachable standard error carries the report");
assert!(String::from_utf8_lossy(&readable.stderr).contains("cannot start /nonexistent/git"));
let unreadable: ExitStatus = Command::new(&entry)
.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(broken_pipe()))
.status()
.expect("the hook entry starts");
assert_eq!(unreadable.code(), Some(1), "{unreadable:?}: standard error had no reader");
remove_settled(&fixture);
}