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

nordisk/gunnar

crates/gunnar-store/tests/object_decode.rs

//! **`gunnar_store::object` against `gix-object`, over the same bytes.**
//!
//! `src/object.rs` exists so that the appliance does not link seven crates to
//! read four grammars. That is only worth having if it reads them *the same
//! way*, and a decoder checked against fixtures the same author wrote is a
//! decoder checked against its own opinion (one implementation, no twin).
//!
//! So `gix-object` stays — as a **dev-dependency**, an implementation with no
//! stake in gunnar's answer. Every case below decodes with both and compares
//! the fields gunnar actually reads. The three tests at the end are the ones
//! that matter most: they are the shapes that are easy to get wrong, and each
//! one was written *before* the code that passes it.

use gunnar_store::gix_hash::{Kind as HashKind, ObjectId};
use gunnar_store::object::{self, EntryKind, Kind};

const SHA1: HashKind = HashKind::Sha1;

fn oid(byte: u8) -> ObjectId {
    ObjectId::from_bytes_or_panic(&[byte; 20])
}

fn hex(id: &ObjectId) -> String {
    id.to_string()
}

// ───────────────────────────────────────────────────────────────────────────
// Fixtures
// ───────────────────────────────────────────────────────────────────────────

fn commit_bytes(parents: &[ObjectId], extra_headers: &str, message: &str) -> Vec<u8> {
    let mut s = format!("tree {}\n", hex(&oid(0x11)));
    for p in parents {
        s.push_str(&format!("parent {}\n", hex(p)));
    }
    s.push_str("author A U Thor <author@example.com> 1700000000 +0200\n");
    s.push_str("committer C O Mitter <committer@example.com> 1723200000 -0730\n");
    s.push_str(extra_headers);
    s.push('\n');
    s.push_str(message);
    s.into_bytes()
}

fn tree_bytes(entries: &[(&str, &str, ObjectId)]) -> Vec<u8> {
    let mut out = Vec::new();
    for (mode, name, id) in entries {
        out.extend_from_slice(mode.as_bytes());
        out.push(b' ');
        out.extend_from_slice(name.as_bytes());
        out.push(0);
        out.extend_from_slice(id.as_bytes());
    }
    out
}

fn tag_bytes(target: &ObjectId, ty: &str) -> Vec<u8> {
    format!(
        "object {}\ntype {ty}\ntag v1.0\ntagger T Agger <tag@example.com> 1700000000 +0000\n\nrelease\n",
        hex(target)
    )
    .into_bytes()
}

// ───────────────────────────────────────────────────────────────────────────
// Commits
// ───────────────────────────────────────────────────────────────────────────

/// Every parent count the walk can meet: root, linear, merge, octopus.
#[test]
fn commit_links_agree_with_gix_for_every_parent_count() {
    for n in [0usize, 1, 2, 7] {
        let parents: Vec<ObjectId> = (0..n).map(|i| oid(0x20 + i as u8)).collect();
        let bytes = commit_bytes(&parents, "", "a message\n");

        let mut mine = Vec::new();
        let tree = object::commit_links(&bytes, SHA1, &mut mine).expect("gunnar decodes");

        let mut iter = gix_object::CommitRefIter::from_bytes(&bytes, SHA1);
        let gix_tree = iter.tree_id().expect("gix decodes");
        let gix_parents: Vec<ObjectId> = gix_object::CommitRefIter::from_bytes(&bytes, SHA1)
            .parent_ids()
            .collect();

        assert_eq!(tree, gix_tree, "{n} parents: tree");
        assert_eq!(mine, gix_parents, "{n} parents: parents");
        assert_eq!(mine, parents, "{n} parents: and the fixture agrees too");
    }
}

/// The committer timestamp, including a **half-hour** zone.
///
/// `-0730` is the case a base-100 reading of the offset gets wrong, and the
/// only reason `seconds` is unaffected by it is that git writes the epoch and
/// the zone as separate fields. Both are checked.
#[test]
fn commit_facts_agree_with_gix_on_the_committer_time() {
    let bytes = commit_bytes(&[oid(0x33)], "", "msg\n");

    let mut mine = Vec::new();
    let (tree, when) = object::commit_facts(&bytes, SHA1, &mut mine).expect("gunnar decodes");

    let gix = gix_object::CommitRef::from_bytes(&bytes, SHA1).expect("gix decodes");
    let gix_committer = gix.committer().expect("gix decodes the committer");

    assert_eq!(tree, gix.tree(), "tree");
    assert_eq!(
        when,
        gix_committer.time().unwrap().seconds,
        "committer seconds"
    );
    assert_eq!(when, 1723200000);

    let full = object::commit(&bytes, SHA1).expect("gunnar decodes the whole commit");
    assert_eq!(
        full.committer.offset_seconds,
        gix_committer.time().unwrap().offset,
        "-0730 is minus seven and a half hours, not minus 730 minutes"
    );
    assert_eq!(full.committer.offset_seconds, -(7 * 3600 + 30 * 60));
}

/// A `gpgsig` header folds over many lines, each continuation beginning with a
/// space. A scanner that does not understand folding must still not mistake a
/// continuation for a header — and must not stop reading at one.
#[test]
fn a_folded_gpgsig_header_does_not_derail_the_scan() {
    let gpgsig = "gpgsig -----BEGIN PGP SIGNATURE-----\n \n iHUEABYKAB0WIQ\n parent deadbeef\n -----END PGP SIGNATURE-----\n";
    let parents = [oid(0x41), oid(0x42)];
    let bytes = commit_bytes(&parents, gpgsig, "signed\n");

    let mut mine = Vec::new();
    let tree = object::commit_links(&bytes, SHA1, &mut mine).unwrap();
    let gix_parents: Vec<ObjectId> = gix_object::CommitRefIter::from_bytes(&bytes, SHA1)
        .parent_ids()
        .collect();

    assert_eq!(mine, gix_parents);
    assert_eq!(
        mine, parents,
        "the ` parent deadbeef` line inside the signature is not a parent"
    );

    let (facts_tree, when) = object::commit_facts(&bytes, SHA1, &mut mine).unwrap();
    assert_eq!(facts_tree, tree);
    assert_eq!(
        when, 1723200000,
        "the committer is still found past the fold"
    );

    let full = object::commit(&bytes, SHA1).unwrap();
    let gix = gix_object::CommitRef::from_bytes(&bytes, SHA1).unwrap();
    assert_eq!(full.parents, parents.to_vec());
    assert_eq!(full.message, b"signed\n");
    // The control plane's `ListCommits` publishes this string, and the fold is
    // exactly where a naive "everything after the first blank line" split gets
    // it wrong — the signature's own blank continuation line comes first.
    assert_eq!(full.message, gix.message.to_vec(), "message");
    assert_eq!(full.tree, gix.tree(), "tree");
}

/// A name containing angle brackets: the split is on the **last** `>`.
#[test]
fn a_name_with_angle_brackets_splits_where_gix_splits_it() {
    let mut s = format!("tree {}\n", hex(&oid(0x11)));
    s.push_str("author A <weird> Thor <author@example.com> 1700000000 +0200\n");
    s.push_str("committer A <weird> Thor <author@example.com> 1700000000 +0200\n");
    s.push_str("\nmsg\n");
    let bytes = s.into_bytes();

    let mine = object::commit(&bytes, SHA1).unwrap();
    let gix = gix_object::CommitRef::from_bytes(&bytes, SHA1).unwrap();
    let gix_author = gix.author().unwrap();

    assert_eq!(mine.author.name, gix_author.name.to_vec(), "name");
    assert_eq!(mine.author.email, gix_author.email.to_vec(), "email");
    assert_eq!(mine.author.seconds, gix_author.time().unwrap().seconds);
}

/// **A recorded divergence, found by this test and not by inspection.**
///
/// A commit with no message has no blank line to end its headers. `gix-object`
/// **rejects** it; gunnar reads it and calls the message empty. The first draft
/// of this test asserted that both accept it, and gix's `Err` is what said
/// otherwise.
///
/// gunnar keeps the lenient reading deliberately. This module never *writes*
/// an object, so nothing downstream can inherit a malformation from it, and on
/// the serve path the bytes go into the pack verbatim regardless of what the
/// decoder thought. Refusing here would turn one odd object in a repository's
/// history into a failed clone of the whole repository, which is the trade the
/// tolerance is made against.
#[test]
fn a_headers_only_commit_is_where_gunnar_is_deliberately_more_tolerant_than_gix() {
    let mut s = format!("tree {}\n", hex(&oid(0x11)));
    s.push_str("author A <a@e> 1 +0000\ncommitter C <c@e> 2 +0000\n");
    let bytes = s.into_bytes();

    let mine = object::commit(&bytes, SHA1).expect("gunnar reads it");
    assert_eq!(mine.tree, oid(0x11));
    assert!(mine.message.is_empty());

    assert!(
        gix_object::CommitRef::from_bytes(&bytes, SHA1).is_err(),
        "gix refuses a commit with no blank line; if this ever starts passing, \
         the divergence documented in src/object.rs is stale"
    );

    // The two traversal readers agree with the full one.
    let mut parents = Vec::new();
    assert_eq!(
        object::commit_links(&bytes, SHA1, &mut parents).unwrap(),
        oid(0x11)
    );
    assert_eq!(
        object::commit_facts(&bytes, SHA1, &mut parents).unwrap(),
        (oid(0x11), 2)
    );
}

/// A commit whose committer line has no time is worth an ordering, not a
/// clone. This is the one documented divergence from a strict reader, and it
/// is the behaviour the call sites already had via `.unwrap_or_default()`.
#[test]
fn an_unreadable_committer_time_is_zero_and_not_an_error() {
    let mut s = format!("tree {}\n", hex(&oid(0x11)));
    s.push_str("parent ");
    s.push_str(&hex(&oid(0x55)));
    s.push_str("\ncommitter C O Mitter <c@e>\n\nmsg\n");
    let bytes = s.into_bytes();

    let mut parents = Vec::new();
    let (tree, when) = object::commit_facts(&bytes, SHA1, &mut parents).unwrap();
    assert_eq!(tree, oid(0x11));
    assert_eq!(parents, vec![oid(0x55)]);
    assert_eq!(when, 0);
}

#[test]
fn a_commit_with_no_tree_is_an_error_in_all_three_readers() {
    let bytes = b"parent 1111111111111111111111111111111111111111\n\nmsg\n";
    let mut parents = Vec::new();
    assert!(object::commit_links(bytes, SHA1, &mut parents).is_err());
    assert!(object::commit_facts(bytes, SHA1, &mut parents).is_err());
    assert!(object::commit(bytes, SHA1).is_err());
    assert!(gix_object::CommitRef::from_bytes(bytes, SHA1).is_err());
}

#[test]
fn a_short_oid_in_a_header_is_an_error() {
    let bytes = b"tree 1111\n\nmsg\n";
    let mut parents = Vec::new();
    assert!(object::commit_links(bytes, SHA1, &mut parents).is_err());
}

/// `parents` belongs to the caller and is refilled, not appended to.
#[test]
fn the_parent_buffer_is_cleared_between_commits() {
    let mut parents = vec![oid(0xEE); 4];
    let bytes = commit_bytes(&[oid(0x60)], "", "m\n");
    object::commit_links(&bytes, SHA1, &mut parents).unwrap();
    assert_eq!(parents, vec![oid(0x60)]);
}

// ───────────────────────────────────────────────────────────────────────────
// Trees
// ───────────────────────────────────────────────────────────────────────────

/// Every mode git writes, plus the two spellings of a tree mode.
#[test]
fn tree_entries_agree_with_gix_on_oid_and_kind() {
    let entries = [
        ("100644", "readme", oid(0x01)),
        ("100755", "run.sh", oid(0x02)),
        ("120000", "link", oid(0x03)),
        ("40000", "dir", oid(0x04)),
        ("040000", "dir2", oid(0x05)),
        ("160000", "submodule", oid(0x06)),
    ];
    let bytes = tree_bytes(&entries);

    let mine: Vec<_> = object::TreeIter::from_bytes(&bytes, SHA1)
        .collect::<Result<Vec<_>, _>>()
        .expect("gunnar decodes");
    let gix: Vec<_> = gix_object::TreeRefIter::from_bytes(&bytes, SHA1)
        .collect::<Result<Vec<_>, _>>()
        .expect("gix decodes");

    assert_eq!(mine.len(), gix.len());
    assert_eq!(mine.len(), entries.len());
    for (m, g) in mine.iter().zip(gix.iter()) {
        assert_eq!(m.oid, g.oid.to_owned(), "oid");
        let same_kind = match g.mode.kind() {
            gix_object::tree::EntryKind::Tree => m.kind == EntryKind::Tree,
            gix_object::tree::EntryKind::Blob => m.kind == EntryKind::Blob,
            gix_object::tree::EntryKind::BlobExecutable => m.kind == EntryKind::BlobExecutable,
            gix_object::tree::EntryKind::Link => m.kind == EntryKind::Link,
            gix_object::tree::EntryKind::Commit => m.kind == EntryKind::Commit,
        };
        assert!(
            same_kind,
            "kind: gunnar {:?} vs gix {:?}",
            m.kind,
            g.mode.kind()
        );
    }

    // The distinction the closure walk actually turns on.
    assert_eq!(mine[3].kind, EntryKind::Tree, "`40000` is a tree");
    assert_eq!(mine[4].kind, EntryKind::Tree, "`040000` is the same tree");
    assert_eq!(
        mine[5].kind,
        EntryKind::Commit,
        "a gitlink is not ours to send"
    );
}

#[test]
fn an_empty_tree_yields_no_entries() {
    assert_eq!(object::TreeIter::from_bytes(&[], SHA1).count(), 0);
}

/// A truncated oid must be an error, not a short read that looks fine.
#[test]
fn a_tree_whose_last_oid_is_truncated_is_an_error() {
    let mut bytes = tree_bytes(&[("100644", "a", oid(0x01))]);
    bytes.truncate(bytes.len() - 3);
    let got: Result<Vec<_>, _> = object::TreeIter::from_bytes(&bytes, SHA1).collect();
    assert!(got.is_err());
    assert!(gix_object::TreeRefIter::from_bytes(&bytes, SHA1)
        .collect::<Result<Vec<_>, _>>()
        .is_err());
}

/// After an error the iterator stops. Resynchronising in a corrupt buffer
/// produces entries that look well-formed and are not.
#[test]
fn a_failed_tree_iterator_does_not_keep_yielding() {
    let mut bytes = tree_bytes(&[("100644", "a", oid(0x01))]);
    bytes.extend_from_slice(b"99xx bad\0short");
    let mut it = object::TreeIter::from_bytes(&bytes, SHA1);
    assert!(it.next().unwrap().is_ok());
    assert!(it.next().unwrap().is_err());
    assert!(it.next().is_none());
}

// ───────────────────────────────────────────────────────────────────────────
// Tags
// ───────────────────────────────────────────────────────────────────────────

#[test]
fn tag_targets_agree_with_gix() {
    for ty in ["commit", "tree", "blob", "tag"] {
        let target = oid(0x77);
        let bytes = tag_bytes(&target, ty);

        let mine = object::tag(&bytes, SHA1).expect("gunnar decodes");
        let gix = gix_object::TagRef::from_bytes(&bytes, SHA1).expect("gix decodes");

        assert_eq!(mine.target, gix.target(), "{ty}: target");
        assert_eq!(mine.target, target);
        assert_eq!(
            mine.target_kind,
            Kind::from_bytes(ty.as_bytes()),
            "{ty}: type"
        );
        assert_eq!(object::tag_target(&bytes, SHA1).unwrap(), gix.target());
    }
}

#[test]
fn a_tag_with_no_object_header_is_an_error() {
    let bytes = b"type commit\ntag v1\n\nmsg\n";
    assert!(object::tag(bytes, SHA1).is_err());
}

// ───────────────────────────────────────────────────────────────────────────
// The kind mapping
// ───────────────────────────────────────────────────────────────────────────

/// The contract's two delta codes cannot come out of a resolved read, and this
/// is the one place that is enforced.
#[test]
fn the_delta_codes_do_not_resolve_to_a_kind() {
    use gunnar_store::git_storage_trait::ObjType;
    assert_eq!(Kind::resolved(ObjType::Commit), Some(Kind::Commit));
    assert_eq!(Kind::resolved(ObjType::Tree), Some(Kind::Tree));
    assert_eq!(Kind::resolved(ObjType::Blob), Some(Kind::Blob));
    assert_eq!(Kind::resolved(ObjType::Tag), Some(Kind::Tag));
    assert_eq!(Kind::resolved(ObjType::OfsDelta), None);
    assert_eq!(Kind::resolved(ObjType::RefDelta), None);
}

/// The four names are git's, and they are what error messages print.
#[test]
fn the_kind_names_are_gits_own() {
    for (mine, gix) in [
        (Kind::Commit, gix_object::Kind::Commit),
        (Kind::Tree, gix_object::Kind::Tree),
        (Kind::Blob, gix_object::Kind::Blob),
        (Kind::Tag, gix_object::Kind::Tag),
    ] {
        assert_eq!(mine.to_string(), gix.to_string());
        assert_eq!(Kind::from_bytes(mine.as_str().as_bytes()), Some(mine));
    }
}

/// ★ The named lookup finds exactly the asked-for entry, byte-compared.
///
/// `TreeIter` drops names on purpose (its callers walk edges); the log's path
/// filter is the caller that cannot, and this is its primitive. The absent arm
/// is the identity-value guard: a lookup that returned the first entry for any
/// name would pass the present arms alone.
#[test]
fn a_named_lookup_finds_its_entry_and_only_its_entry() {
    let entries = [
        ("100644", "readme", oid(0x01)),
        ("40000", "src", oid(0x02)),
        ("100644", "readme2", oid(0x03)),
    ];
    let bytes = tree_bytes(&entries);

    let readme = object::tree_entry_named(&bytes, SHA1, b"readme")
        .expect("decodes")
        .expect("present");
    assert_eq!(readme.oid, oid(0x01));
    assert_eq!(readme.kind, EntryKind::Blob);

    let src = object::tree_entry_named(&bytes, SHA1, b"src")
        .expect("decodes")
        .expect("present");
    assert_eq!(src.oid, oid(0x02));
    assert_eq!(src.kind, EntryKind::Tree);

    // A PREFIX of a real name is not the name.
    assert!(object::tree_entry_named(&bytes, SHA1, b"read")
        .expect("decodes")
        .is_none());
    assert!(object::tree_entry_named(&bytes, SHA1, b"absent")
        .expect("decodes")
        .is_none());

    // The corruption rule is the iterator's: a torn tree is an error even when
    // the tear sits after the entry being asked for was already parsed... and
    // BEFORE it too — never a resynchronised guess.
    let mut torn = tree_bytes(&entries);
    torn.truncate(torn.len() - 3);
    assert!(object::tree_entry_named(&torn, SHA1, b"absent").is_err());
}