gunnar-sendpack 1.1.0

git's receive-pack wire format, both ends: the send-pack conversation gitoxide does not have, plus the server-side encoders for the same grammar. Plumbing only, no gunnar types.
Documentation
//! A remote that refuses, in words.
//!
//! `ERR <msg>` is the one line in the receive-pack conversation that is a
//! sentence for a human rather than a record for a parser, and sideband 3 is
//! its counterpart once the report has started. gunnar read the first as a ref
//! and ignored the second when computing its verdict, so a refusal reached the
//! operator as a parse error or as exit 0.
//!
//! **The judge here is stock git.** The same refusing `receive-pack` — one
//! shell script — is driven once by `git push` and once by this crate, and the
//! two are required to say the same thing. Receive-pack is protocol v0 and only
//! v0, so there is no v2 arm to run; the hash kind never enters an `ERR`
//! packet, so there is no second hash arm either.

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())
}

/// A `receive-pack` that answers `ERR <msg>` and nothing else.
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
}

/// git and gunnar, told the same refusal by the same script.
#[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());

    // Somewhere to push from and something to push at; neither is ever
    // reached, because the refusal comes before the client speaks.
    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:?}");
    }

    // What stock git says.
    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");

    // What gunnar says, reading the same script's stdout.
    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:?}"
    );
}

/// Sideband 3 is fatal. A report that says `unpack ok` on band 1 while band 3
/// carries an abort is not a successful push.
#[test]
fn a_band_three_abort_fails_the_push_even_when_band_one_says_unpack_ok() {
    // Band 1: `unpack ok`, `ok refs/heads/main`, flush — a perfect report.
    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:?}"
    );
}

/// The control for the guard above: the same report **without** band 3 must
/// still pass, or all that has been proven is that `is_ok` can return false.
#[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());
}

/// `ERR` inside the report stream, where git reads every packet with
/// `PACKET_READ_DIE_ON_ERR_PACKET`.
#[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}"));
}