use std::io::Write;
use std::process::{Command, Stdio};
use gunnar_sendpack::{read_advertisement, read_report, Error, PushReport, RefStatus};
const REFUSAL: &str = "you are not allowed to push here";
fn have_git() -> bool {
Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn refusing_receive_pack(dir: &std::path::Path) -> std::path::PathBuf {
let path = dir.join("refuse.sh");
std::fs::write(
&path,
format!(
"#!/bin/sh\n\
msg='ERR {REFUSAL}'\n\
len=$(printf '%s\\n' \"$msg\" | wc -c)\n\
printf '%04x%s\\n' $((len+4)) \"$msg\"\n\
printf '0000'\n\
exit 0\n"
),
)
.unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
#[test]
fn an_err_advertisement_is_the_remote_speaking_not_a_bad_object_id() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let script = refusing_receive_pack(dir.path());
let work = dir.path().join("work");
let bare = dir.path().join("bare.git");
for args in [
vec!["init", "--quiet", "--initial-branch=main", "work"],
vec![
"init",
"--quiet",
"--bare",
"--initial-branch=main",
"bare.git",
],
] {
let out = Command::new("git")
.args(&args)
.current_dir(dir.path())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}");
}
std::fs::write(work.join("f"), "x\n").unwrap();
for args in [
vec!["add", "."],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@x.invalid",
"commit",
"-qm",
"c",
],
] {
let out = Command::new("git")
.args(&args)
.current_dir(&work)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}");
}
let out = Command::new("git")
.args([
"push",
&format!("--receive-pack={}", script.display()),
bare.to_str().unwrap(),
"main",
])
.current_dir(&work)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
let git_said = String::from_utf8_lossy(&out.stderr).trim().to_string();
assert!(
git_said.contains(&format!("remote error: {REFUSAL}")),
"git 2.53.0 was expected to quote the refusal; it said {git_said:?}"
);
assert!(!out.status.success(), "git exited 0 on a refused push");
let mut child = Command::new(&script)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let err = read_advertisement(&mut stdout).expect_err("the refusal must not parse as refs");
let _ = child.wait();
assert!(
matches!(err, Error::Remote(ref m) if m == REFUSAL),
"the refusal was mangled instead of reported; got {err:?}"
);
assert_eq!(
err.to_string(),
format!("remote error: {REFUSAL}"),
"gunnar and git must say the same sentence; git said {git_said:?}"
);
}
#[test]
fn a_band_three_abort_fails_the_push_even_when_band_one_says_unpack_ok() {
let mut inner = Vec::new();
for line in [b"unpack ok\n".to_vec(), b"ok refs/heads/main\n".to_vec()] {
write!(inner, "{:04x}", line.len() + 4).unwrap();
inner.extend_from_slice(&line);
}
inner.extend_from_slice(b"0000");
let mut wire = Vec::new();
let mut band = |n: u8, payload: &[u8]| {
let mut p = vec![n];
p.extend_from_slice(payload);
write!(wire, "{:04x}", p.len() + 4).unwrap();
wire.extend_from_slice(&p);
};
band(1, &inner);
band(3, b"the remote gave up: disk full");
wire.extend_from_slice(b"0000");
let mut cursor = std::io::Cursor::new(wire);
let report = read_report(&mut cursor, true).expect("the report itself is well formed");
assert_eq!(report.unpack, "ok");
assert_eq!(report.remote_errors, ["the remote gave up: disk full"]);
assert!(
!report.is_ok(),
"a band-3 abort exited 0; the reason was on the screen and the verdict disagreed"
);
let summary = report
.failure_summary()
.expect("a failed push has a summary");
assert!(
summary.contains("disk full"),
"the summary did not carry the remote's own words; got {summary:?}"
);
}
#[test]
fn the_same_report_without_a_band_three_abort_is_still_a_success() {
let mut report = PushReport {
unpack: "ok".into(),
refs: vec![RefStatus::ok("refs/heads/main")],
..Default::default()
};
report.reconcile(&["refs/heads/main".into()]);
assert!(report.is_ok(), "{:?}", report.failure_summary());
}
#[test]
fn an_err_line_in_the_report_is_a_refusal_not_an_unrecognised_line() {
let lines = vec![format!("ERR {REFUSAL}").into_bytes()];
let err = gunnar_sendpack::report::parse(&lines).expect_err("ERR must not parse as a status");
assert_eq!(err.to_string(), format!("remote error: {REFUSAL}"));
}