Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/gunnar-sendpack/tests/grammar.rs

//! The four wire formats, each asserted from byte literals, and each asserted
//! **both ways**.
//!
//! Round-tripping an encoder through its own decoder proves only that the two
//! agree, which two wrong implementations also do. So every round-trip here
//! also pins the exact bytes against a literal transcribed from what
//! `git-receive-pack` 2.53.0 puts on the wire; `tests/against_real_git.rs`
//! then drives the real binary, which is the only judge that cannot be wrong in
//! the same way we are.

use gix_hash::{Kind, ObjectId};
use gunnar_sendpack::{
    advertisement, capabilities, command, options, report, Advertisement, Capabilities,
    PushCommand, RefStatus, RemoteRef, SendPackOptions,
};

fn pkt(payload: &str) -> Vec<u8> {
    payload.as_bytes().to_vec()
}

fn oid(c: char) -> ObjectId {
    ObjectId::from_hex(c.to_string().repeat(40).as_bytes()).unwrap()
}

// ── capabilities ────────────────────────────────────────────────────────────

#[test]
fn capabilities_parse_values_and_flags() {
    let c = Capabilities::parse("report-status delete-refs object-format=sha256 agent=git/2.53");
    assert!(c.has("report-status"));
    assert!(c.has("object-format"));
    assert!(!c.has("atomic"));
    assert_eq!(c.value("object-format"), Some("sha256"));
    assert_eq!(c.value("agent"), Some("git/2.53"));
    assert_eq!(c.value("report-status"), None, "a flag has no value");
}

/// git's own `receive-pack` client emits `…\0 report-status-v2 …` with a
/// **leading** space. A naive `split(' ')` turns that into a capability whose
/// name is the empty string, which then matches nothing and hides the rest.
#[test]
fn a_leading_space_does_not_become_a_phantom_capability() {
    let c = Capabilities::parse(" report-status-v2  atomic ");
    assert_eq!(c.all(), ["report-status-v2", "atomic"]);
}

/// A prefix is not a capability. `has("report-status")` must not fire on
/// `report-status-v2`, or a client would believe v1 was on offer when only v2
/// was, negotiate v1, and be answered in v2.
#[test]
fn a_capability_is_not_matched_by_a_longer_one_that_starts_with_it() {
    let c = Capabilities::parse("report-status-v2 object-formats-are-not-a-thing");
    assert!(!c.has("report-status"), "v2 is not v1");
    assert!(c.has("report-status-v2"));
    assert_eq!(c.value("object-format"), None);
}

#[test]
fn splitting_the_first_line_distinguishes_an_empty_list_from_no_list() {
    assert_eq!(capabilities::split(b"abc"), (&b"abc"[..], None));
    assert_eq!(
        capabilities::split(b"abc\0"),
        (&b"abc"[..], Some(&b""[..])),
        "a NUL with nothing after it is an EMPTY list, not an absent one"
    );
    assert_eq!(
        capabilities::split(b"abc\0x y"),
        (&b"abc"[..], Some(&b"x y"[..]))
    );
}

// ── the advertisement, both ways ────────────────────────────────────────────

#[test]
fn advertisement_of_a_populated_sha1_repo() {
    let (a, b) = (oid('1'), oid('2'));
    let adv = advertisement::parse(&[
        pkt(&format!(
            "{a} refs/heads/main\0report-status delete-refs object-format=sha1\n"
        )),
        pkt(&format!("{b} refs/tags/v1\n")),
    ])
    .unwrap();
    assert_eq!(adv.hash_kind, Kind::Sha1);
    assert_eq!(adv.refs.len(), 2);
    assert_eq!(adv.refs[0].name, "refs/heads/main");
    assert_eq!(adv.refs[0].oid, a);
    assert_eq!(adv.refs[1].name, "refs/tags/v1");
    assert!(adv.capabilities.has("delete-refs"));
    assert_eq!(adv.oid_of("refs/heads/main"), a);
    assert!(
        adv.oid_of("refs/heads/nope").is_null(),
        "an unknown ref reads as the null oid, which is what a create sends"
    );
}

/// The empty-repository shape. `capabilities^{}` is NOT a ref, and a client
/// that recorded it would push against a ref name that cannot exist.
#[test]
fn advertisement_of_an_empty_repo_has_no_refs_but_has_capabilities() {
    let zero = "0".repeat(40);
    let adv = advertisement::parse(&[pkt(&format!(
        "{zero} capabilities^{{}}\0report-status-v2 object-format=sha1 agent=git/2.53.0\n"
    ))])
    .unwrap();
    assert!(
        adv.refs.is_empty(),
        "capabilities^{{}} must not become a ref"
    );
    assert!(adv.capabilities.has("report-status-v2"));
    assert_eq!(adv.hash_kind, Kind::Sha1);
}

#[test]
fn advertisement_of_a_sha256_repo() {
    let a = "a".repeat(64);
    let adv = advertisement::parse(&[pkt(&format!(
        "{a} refs/heads/main\0report-status object-format=sha256\n"
    ))])
    .unwrap();
    assert_eq!(adv.hash_kind, Kind::Sha256);
    assert_eq!(adv.refs[0].oid.kind(), Kind::Sha256);
    let null = adv.oid_of("refs/heads/nope");
    assert!(
        null.is_null() && null.to_hex().to_string().len() == 64,
        "the null oid of a sha256 remote is 64 hex chars, not 40"
    );
}

/// A server too old to advertise `object-format` still tells the truth with the
/// width of its oids. Assuming sha1 there would make every oid parse fail.
#[test]
fn hash_kind_falls_back_to_oid_width() {
    let a = "b".repeat(64);
    let adv =
        advertisement::parse(&[pkt(&format!("{a} refs/heads/main\0report-status\n"))]).unwrap();
    assert_eq!(adv.hash_kind, Kind::Sha256);
}

#[test]
fn shallow_lines_are_not_refs() {
    let (a, s) = (oid('1'), oid('2'));
    let adv = advertisement::parse(&[
        pkt(&format!("{a} refs/heads/main\0report-status\n")),
        pkt(&format!("shallow {s}\n")),
    ])
    .unwrap();
    assert_eq!(adv.refs.len(), 1);
}

/// An advertisement whose `object-format` and oid width disagree is a server
/// contradicting itself, and the oids are what everything downstream uses.
#[test]
fn an_oid_of_the_wrong_width_for_the_stated_format_is_refused() {
    let a = "a".repeat(64);
    let err = advertisement::parse(&[pkt(&format!("{a} refs/heads/main\0object-format=sha1\n"))])
        .unwrap_err();
    assert!(format!("{err}").contains("Sha1"), "{err}");
}

/// The **server** half of the same format, byte-exact against what
/// `git-receive-pack` 2.53.0 emits.
#[test]
fn the_advertisement_encoder_and_decoder_are_one_format() {
    let caps = vec!["report-status-v2".to_string(), "agent=x/1".to_string()];
    let refs = vec![
        RemoteRef::new("refs/heads/main", oid('1')),
        RemoteRef::new("refs/tags/v1", oid('2')),
    ];
    let lines = advertisement::lines(&refs, &caps, Kind::Sha1);
    assert_eq!(
        lines,
        vec![
            format!("{} refs/heads/main\0report-status-v2 agent=x/1", oid('1')).into_bytes(),
            format!("{} refs/tags/v1", oid('2')).into_bytes(),
        ],
        "capabilities ride on the FIRST line only, after a NUL"
    );

    let round = advertisement::parse(&lines).unwrap();
    assert_eq!(round.refs, refs);
    assert_eq!(round.capabilities.all(), caps);
    assert_eq!(round.hash_kind, Kind::Sha1);
}

/// A repository with no refs still has to say who it is, or the client cannot
/// learn the hash algorithm and reports nothing useful.
#[test]
fn an_empty_repository_still_advertises_one_line() {
    let caps = vec!["object-format=sha256".to_string()];
    let lines = advertisement::lines(&[], &caps, Kind::Sha256);
    assert_eq!(lines.len(), 1);
    let round = advertisement::parse(&lines).unwrap();
    assert!(round.refs.is_empty());
    assert_eq!(round.hash_kind, Kind::Sha256);
}

// ── the command list, both ways ─────────────────────────────────────────────

fn adv_with(caps: &str) -> Advertisement {
    advertisement::parse(&[pkt(&format!("{} refs/heads/main\0{caps}\n", oid('1')))]).unwrap()
}

fn cmd_update() -> PushCommand {
    PushCommand::update("refs/heads/main", oid('1'), oid('2'))
}

#[test]
fn the_command_list_is_byte_exact() {
    let cmds = vec![
        PushCommand::update("refs/heads/main", oid('1'), oid('2')),
        PushCommand::create("refs/heads/next", oid('3')),
    ];
    let lines = command::lines(&cmds, &["report-status".into(), "agent=x/0".into()]).unwrap();
    let zero = "0".repeat(40);
    assert_eq!(
        lines,
        vec![
            format!(
                "{} {} refs/heads/main\0report-status agent=x/0",
                oid('1'),
                oid('2')
            )
            .into_bytes(),
            format!("{zero} {} refs/heads/next", oid('3')).into_bytes(),
        ]
    );
}

/// The half that did not exist before this crate: gunnar's server parsed the
/// command list with code that had never met the encoder that writes it.
#[test]
fn the_command_encoder_and_decoder_are_one_format() {
    let cmds = vec![
        PushCommand::update("refs/heads/main", oid('1'), oid('2')),
        PushCommand::create("refs/heads/next", oid('3')),
        PushCommand::delete("refs/tags/old", oid('4')),
    ];
    let caps = vec!["report-status-v2".to_string()];
    let lines = command::lines(&cmds, &caps).unwrap();

    let mut back = Vec::new();
    for (i, line) in lines.iter().enumerate() {
        let (content, sent_caps) = capabilities::split(line);
        assert_eq!(
            sent_caps.is_some(),
            i == 0,
            "only the first line carries capabilities"
        );
        back.push(command::parse_line(content, Kind::Sha1).unwrap());
    }
    assert_eq!(back, cmds);
}

/// Reference names are bytes. A `String` field forces a lossy conversion, and a
/// lossy conversion collapses two distinct names onto one — so a push to one
/// silently overwrites the other.
#[test]
fn a_non_utf8_reference_name_survives_the_round_trip_intact() {
    let a: &[u8] = b"refs/heads/\xff";
    let b: &[u8] = b"refs/heads/\xfe";
    assert_ne!(a, b);
    assert_eq!(
        String::from_utf8_lossy(a),
        String::from_utf8_lossy(b),
        "the two names are INDISTINGUISHABLE after a lossy conversion, which is \
         the bug this test exists to keep fixed"
    );

    let cmds = vec![
        PushCommand::create(a.to_vec(), oid('1')),
        PushCommand::create(b.to_vec(), oid('2')),
    ];
    let lines = command::lines(&cmds, &[]).unwrap();
    let back: Vec<_> = lines
        .iter()
        .map(|l| command::parse_line(capabilities::split(l).0, Kind::Sha1).unwrap())
        .collect();
    assert_eq!(back, cmds);
    assert_ne!(back[0].name, back[1].name);
}

#[test]
fn a_duplicated_refname_in_the_command_list_is_refused() {
    let cmds = vec![
        PushCommand::update("refs/heads/main", oid('1'), oid('2')),
        PushCommand::update("refs/heads/main", oid('2'), oid('1')),
    ];
    assert!(command::lines(&cmds, &[]).is_err());
}

#[test]
fn an_empty_command_list_is_refused() {
    assert!(command::lines(&[], &["report-status".into()]).is_err());
}

/// A newline in a ref name would be read as the end of the line and everything
/// after it as a second command.
#[test]
fn a_reference_name_carrying_a_newline_or_a_nul_is_refused() {
    for bad in [&b"refs/heads/a\nb"[..], &b"refs/heads/a\0b"[..]] {
        let cmds = vec![PushCommand::create(bad.to_vec(), oid('1'))];
        assert!(
            command::lines(&cmds, &[]).is_err(),
            "{:?} must not reach the wire",
            String::from_utf8_lossy(bad)
        );
    }
}

/// A 40-hex id in a sha256 repository names nothing. It is refused here rather
/// than downstream, and as a **structured** error rather than a message,
/// because a server re-reports it to the client in its own vocabulary and
/// digging two algorithm names back out of a formatted string is how a message
/// becomes an API by accident.
#[test]
fn a_command_whose_oid_is_the_wrong_hash_kind_is_refused() {
    let line = format!("{} {} refs/heads/main", oid('1'), oid('2'));
    assert!(command::parse_line(line.as_bytes(), Kind::Sha1).is_ok());
    let err = command::parse_line(line.as_bytes(), Kind::Sha256).unwrap_err();
    assert!(
        matches!(
            err,
            gunnar_sendpack::Error::ObjectFormat {
                ours: "sha256",
                theirs: "sha1"
            }
        ),
        "{err:?}"
    );
    assert_eq!(
        err.to_string(),
        "an object id is sha1 but the repository is sha256"
    );
}

#[test]
fn a_command_with_two_null_oids_is_neither_a_create_nor_a_delete() {
    let zero = "0".repeat(40);
    let err = command::parse_line(
        format!("{zero} {zero} refs/heads/main").as_bytes(),
        Kind::Sha1,
    )
    .unwrap_err();
    assert!(format!("{err}").contains("null"), "{err}");
}

// ── negotiation ─────────────────────────────────────────────────────────────

#[test]
fn negotiation_prefers_v2_and_echoes_object_format() {
    let adv =
        adv_with("report-status report-status-v2 side-band-64k object-format=sha1 agent=git/2.53");
    let caps = options::negotiate(
        &adv,
        &[cmd_update()],
        Kind::Sha1,
        &SendPackOptions::default(),
    )
    .unwrap();
    assert!(caps.contains(&"report-status-v2".to_string()));
    assert!(
        !caps.contains(&"report-status".to_string()),
        "v2 replaces v1"
    );
    assert!(caps.contains(&"side-band-64k".to_string()));
    assert!(caps.contains(&"object-format=sha1".to_string()));
    assert!(caps.iter().any(|c| c.starts_with("agent=")));
}

#[test]
fn negotiation_falls_back_to_report_status_v1() {
    let adv = adv_with("report-status delete-refs");
    let caps = options::negotiate(
        &adv,
        &[cmd_update()],
        Kind::Sha1,
        &SendPackOptions::default(),
    )
    .unwrap();
    assert!(caps.contains(&"report-status".to_string()));
    assert!(
        !caps.iter().any(|c| c.starts_with("object-format")),
        "object-format is echoed only when the remote raised it"
    );
    assert!(!caps.contains(&"side-band-64k".to_string()));
}

/// The guard that matters: a requested guarantee that is not on offer must
/// FAIL, never be dropped. Asserted on the error, not on the absence of the
/// capability, because the absence is also what a silent downgrade looks like.
#[test]
fn atomic_is_refused_rather_than_downgraded() {
    let opts = SendPackOptions {
        atomic: true,
        ..Default::default()
    };
    let err = options::negotiate(
        &adv_with("report-status"),
        &[cmd_update()],
        Kind::Sha1,
        &opts,
    )
    .unwrap_err();
    assert!(
        format!("{err}").contains("atomic"),
        "the error must name the guarantee that was refused: {err}"
    );
    let caps = options::negotiate(
        &adv_with("report-status atomic"),
        &[cmd_update()],
        Kind::Sha1,
        &opts,
    )
    .unwrap();
    assert!(caps.contains(&"atomic".to_string()));
}

#[test]
fn push_options_are_refused_rather_than_dropped() {
    let opts = SendPackOptions {
        push_options: vec!["ci.skip".to_string()],
        ..Default::default()
    };
    assert!(options::negotiate(
        &adv_with("report-status"),
        &[cmd_update()],
        Kind::Sha1,
        &opts
    )
    .is_err());
    let caps = options::negotiate(
        &adv_with("report-status push-options"),
        &[cmd_update()],
        Kind::Sha1,
        &opts,
    )
    .unwrap();
    assert!(caps.contains(&"push-options".to_string()));
}

#[test]
fn deleting_without_delete_refs_is_refused() {
    let del = PushCommand::delete("refs/heads/main", oid('1'));
    let d = SendPackOptions::default();
    assert!(options::negotiate(
        &adv_with("report-status"),
        std::slice::from_ref(&del),
        Kind::Sha1,
        &d
    )
    .is_err());
    assert!(options::negotiate(
        &adv_with("report-status delete-refs"),
        &[del],
        Kind::Sha1,
        &d
    )
    .is_ok());
}

#[test]
fn pushing_across_hash_kinds_is_refused() {
    let err = options::negotiate(
        &adv_with("report-status object-format=sha1"),
        &[cmd_update()],
        Kind::Sha256,
        &SendPackOptions::default(),
    )
    .unwrap_err();
    assert!(format!("{err}").contains("object format"), "{err}");
}

// ── the report, both ways ───────────────────────────────────────────────────

#[test]
fn report_status_v1_is_parsed() {
    let r = report::parse(&[
        pkt("unpack ok\n"),
        pkt("ok refs/heads/main\n"),
        pkt("ng refs/heads/next non-fast-forward\n"),
    ])
    .unwrap();
    assert_eq!(r.unpack, "ok");
    assert!(r.refs[0].accepted);
    assert!(!r.refs[1].accepted);
    assert_eq!(r.refs[1].reason.as_deref(), Some("non-fast-forward"));
    assert!(!r.is_ok(), "one ng makes the whole push not ok");
    assert!(r.failure_summary().unwrap().contains("non-fast-forward"));
}

/// `unpack ok` with every ref rejected is a **failed** push that reports a
/// successful unpack. A client that keyed off `unpack` alone would call it
/// green: the identity-value trap, in protocol form.
#[test]
fn unpack_ok_with_all_refs_rejected_is_not_ok() {
    let r = report::parse(&[
        pkt("unpack ok\n"),
        pkt("ng refs/heads/main hook declined\n"),
    ])
    .unwrap();
    assert_eq!(r.unpack, "ok");
    assert!(!r.is_ok());
}

#[test]
fn report_status_v2_options_attach_to_their_ref() {
    let r = report::parse(&[
        pkt("unpack ok\n"),
        pkt("ok refs/heads/main\n"),
        pkt("option old-oid 1111111111111111111111111111111111111111\n"),
        pkt("option forced-update\n"),
    ])
    .unwrap();
    assert_eq!(r.refs.len(), 1);
    assert_eq!(
        r.refs[0].options,
        vec![
            (
                "old-oid".to_string(),
                Some("1111111111111111111111111111111111111111".to_string())
            ),
            ("forced-update".to_string(), None),
        ]
    );
}

#[test]
fn an_option_before_any_ref_status_is_a_protocol_error() {
    assert!(report::parse(&[pkt("unpack ok\n"), pkt("option forced-update\n")]).is_err());
}

#[test]
fn a_failed_unpack_is_carried_verbatim() {
    let r = report::parse(&[pkt("unpack index-pack abnormal exit\n")]).unwrap();
    assert_eq!(r.unpack, "index-pack abnormal exit");
    assert!(!r.is_ok());
}

/// The server half, which gunnar's `receive-pack` used to write separately.
#[test]
fn the_report_encoder_and_decoder_are_one_format() {
    let statuses = vec![
        RefStatus::ok("refs/heads/main"),
        RefStatus::rejected("refs/heads/next", "non-fast-forward"),
    ];
    let lines = report::lines(Ok(()), &statuses);
    assert_eq!(
        lines,
        vec![
            b"unpack ok".to_vec(),
            b"ok refs/heads/main".to_vec(),
            b"ng refs/heads/next non-fast-forward".to_vec(),
        ]
    );
    let round = report::parse(&lines).unwrap();
    assert_eq!(round.refs, statuses);
    assert_eq!(round.unpack, "ok");
    assert!(!round.is_ok());
}

#[test]
fn a_refused_pack_is_encoded_and_read_back() {
    let lines = report::lines(Err("index-pack abnormal exit"), &[]);
    assert_eq!(lines, vec![b"unpack index-pack abnormal exit".to_vec()]);
    assert_eq!(
        report::parse(&lines).unwrap().unpack,
        "index-pack abnormal exit"
    );
}

/// A status reason is **one line**, and a rejection reason is the one field on
/// this wire that carries text a pusher can influence — a hook's stderr, a
/// gate's message.
///
/// This asserts the flattening itself, on the bytes, and not the stronger claim
/// it is tempting to write. *"A newline cannot forge a second status"* is
/// **not** testable here and is not asserted: pkt-lines are length-delimited,
/// so an embedded newline stays inside its own packet and
/// [`report::parse`] still returns exactly one status either way. Writing that
/// test would produce a guard that passes with the flattening removed — which
/// is precisely what the first version of this test did.
///
/// What the flattening does buy is a peer that splits on newlines, and a log
/// line that is one log line. Both are real; neither is visible to a decoder,
/// so the assertion is on the encoder's output.
#[test]
fn a_rejection_reason_is_flattened_to_one_line_before_it_reaches_the_wire() {
    let statuses = vec![RefStatus::rejected(
        "refs/heads/main",
        "denied\nok refs/heads/secret",
    )];
    let lines = report::lines(Ok(()), &statuses);
    assert_eq!(lines.len(), 2, "unpack + exactly one status");
    assert_eq!(
        lines[1],
        b"ng refs/heads/main denied ok refs/heads/secret".to_vec(),
        "the newline must have become a space"
    );
    for line in &lines {
        assert!(
            !line.contains(&b'\n') && !line.contains(&b'\r'),
            "no payload may carry a line break: {:?}",
            String::from_utf8_lossy(line)
        );
    }
    // The same for a refused pack, whose text comes from an indexer.
    let refused = report::lines(Err("index-pack died\nfatal: boom"), &[]);
    assert_eq!(refused[0], b"unpack index-pack died fatal: boom".to_vec());
}

#[test]
fn v2_options_round_trip_through_the_encoder() {
    let statuses = vec![RefStatus {
        name: "refs/heads/main".into(),
        accepted: true,
        reason: None,
        options: vec![
            ("old-oid".to_string(), Some(oid('1').to_string())),
            ("forced-update".to_string(), None),
        ],
    }];
    let lines = report::lines(Ok(()), &statuses);
    assert_eq!(
        lines,
        vec![
            b"unpack ok".to_vec(),
            b"ok refs/heads/main".to_vec(),
            format!("option old-oid {}", oid('1')).into_bytes(),
            b"option forced-update".to_vec(),
        ]
    );
    assert_eq!(report::parse(&lines).unwrap().refs, statuses);
}