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

nordisk/edda

crates/gunnar-pack/tests/roundtrip.rs

//! The verification that matters: **gix indexes what gunnar-pack wrote**.
//!
//! Not one assertion here re-reads gunnar-pack's bytes with gunnar-pack's own reader.
//! A writer and a reader built side by side can share a misunderstanding of the format
//! and agree with each other perfectly (LAW 2 / TOTAL-PROMPT §15), so every claim about
//! a produced pack is made by handing it to `gix_pack::Bundle::write_to_directory` and
//! then reading the objects back out through gix's object database.

mod common;

use std::io::Cursor;

use common::{read_from_pack_dir, thin_pack_with_ref_delta, MemObjects, PackDir};
use gix_hash::Kind;
use gunnar_pack::{
    empty_pack, index_pack, index_thin_pack, missing_objects, write_pack_to_vec, Error,
    IndexOptions, PackWriter, WriteOptions,
};

/// Hand `pack` to gix and let *it* say what is in there.
fn index_with_gix(pack: &[u8], hash_kind: Kind) -> (PackDir, gunnar_pack::IndexedPack) {
    let dir = PackDir::new();
    let indexed = index_pack(
        &mut Cursor::new(pack),
        Some(&dir.pack_dir()),
        &IndexOptions::new(hash_kind),
        None,
    )
    .expect("gix should index a pack written by gunnar-pack");
    (dir, indexed)
}

// ─────────────────────────────── the format itself ──────────────────────────────

#[test]
fn header_is_the_literal_bytes_git_expects() {
    let mut store = MemObjects::new(Kind::Sha1);
    let ids = vec![store.insert_blob("one"), store.insert_blob("two")];

    let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();

    assert_eq!(&pack[0..4], b"PACK");
    assert_eq!(&pack[4..8], &[0, 0, 0, 2], "pack version 2");
    assert_eq!(&pack[8..12], &[0, 0, 0, 2], "two objects, big endian");
}

#[test]
fn trailer_width_follows_the_hash_kind() {
    let sha1 = empty_pack(Kind::Sha1).unwrap();
    let sha256 = empty_pack(Kind::Sha256).unwrap();

    assert_eq!(sha1.len(), 12 + 20, "header plus a 20 byte sha1 trailer");
    assert_eq!(
        sha256.len(),
        12 + 32,
        "header plus a 32 byte sha256 trailer"
    );
    assert_eq!(sha1[..12], sha256[..12], "identical headers");
    assert_ne!(
        sha1[12..32],
        sha256[12..32],
        "different hash functions over the same header must differ"
    );
}

// ─────────────────────────────── blobs, both hash kinds ─────────────────────────

#[test]
fn sha1_blobs_index_and_read_back_byte_exact() {
    let mut store = MemObjects::new(Kind::Sha1);
    let payloads: Vec<Vec<u8>> = vec![
        b"hello gunnar".to_vec(),
        Vec::new(),                     // an empty blob is a real object
        vec![0u8, 1, 2, 255, 254, 128], // binary, not text
        vec![b'x'; 200_000],            // bigger than one deflate buffer
    ];
    let ids: Vec<_> = payloads
        .iter()
        .map(|p| store.insert_blob(p.clone()))
        .collect();

    let (pack, outcome) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
    let (dir, indexed) = index_with_gix(&pack, Kind::Sha1);

    assert_eq!(indexed.num_objects, 4, "gix counted what we declared");
    assert_eq!(outcome.num_objects, 4);
    assert_eq!(
        indexed.pack_id, outcome.pack_id,
        "our trailer and gix's recomputed pack checksum must agree"
    );
    assert_eq!(outcome.bytes_written as usize, pack.len());

    for (id, payload) in ids.iter().zip(&payloads) {
        let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, id)
            .expect("object must be readable from the pack");
        assert_eq!(kind, gix_object::Kind::Blob);
        assert_eq!(&data, payload, "blob {id} must come back byte-exact");
    }
}

#[test]
fn sha256_blobs_index_and_read_back_byte_exact() {
    let mut store = MemObjects::new(Kind::Sha256);
    let payload = b"a sha256 repository is not a sha1 repository".to_vec();
    let id = store.insert_blob(payload.clone());
    assert_eq!(id.kind(), Kind::Sha256);
    assert_eq!(id.as_slice().len(), 32, "sha256 ids are 32 bytes wide");

    let (pack, outcome) =
        write_pack_to_vec(&[id], &store, &WriteOptions::new(Kind::Sha256)).unwrap();
    assert_eq!(outcome.pack_id.kind(), Kind::Sha256);

    let (dir, indexed) = index_with_gix(&pack, Kind::Sha256);
    assert_eq!(indexed.num_objects, 1);
    assert_eq!(indexed.hash_kind, Kind::Sha256);
    assert_eq!(indexed.pack_id, outcome.pack_id);

    let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha256, &id).expect("readable");
    assert_eq!(kind, gix_object::Kind::Blob);
    assert_eq!(data, payload);
}

#[test]
fn a_sha256_pack_is_not_readable_as_sha1() {
    // The red side of the previous test: if sha256 were quietly being written as sha1,
    // this would succeed and the sha256 green above would prove nothing.
    let mut store = MemObjects::new(Kind::Sha256);
    let id = store.insert_blob("payload");
    let (pack, _) = write_pack_to_vec(&[id], &store, &WriteOptions::new(Kind::Sha256)).unwrap();

    let dir = PackDir::new();
    let result = index_pack(
        &mut Cursor::new(&pack),
        Some(&dir.pack_dir()),
        &IndexOptions::new(Kind::Sha1),
        None,
    );
    assert!(
        result.is_err(),
        "a sha256 pack indexed as sha1 must fail, got {:?}",
        result.map(|r| r.num_objects)
    );
}

// ─────────────────────────────── trees and commits ──────────────────────────────

fn tree_entry(
    name: &str,
    oid: gix_hash::ObjectId,
    kind: gix_object::tree::EntryKind,
) -> gix_object::tree::Entry {
    gix_object::tree::Entry {
        mode: kind.into(),
        filename: name.into(),
        oid,
    }
}

fn history_round_trip(hash_kind: Kind) {
    let mut store = MemObjects::new(hash_kind);

    let readme = store.insert_blob("# gunnar\n");
    let src = store.insert_blob("fn main() {}\n");
    let subtree = store.insert_tree(vec![tree_entry(
        "main.rs",
        src,
        gix_object::tree::EntryKind::Blob,
    )]);
    let root = store.insert_tree(vec![
        tree_entry("README.md", readme, gix_object::tree::EntryKind::Blob),
        tree_entry("src", subtree, gix_object::tree::EntryKind::Tree),
    ]);
    let first = store.insert_commit(root, &[], "first\n");
    let second = store.insert_commit(root, &[first], "second, with a parent\n");
    let tag = store.insert_tag(second, "v1.0.0", "the release\n");

    // blob, tree, commit and tag: every object kind the type field can carry.
    let ids = vec![readme, src, subtree, root, first, second, tag];
    let (pack, outcome) = write_pack_to_vec(&ids, &store, &WriteOptions::new(hash_kind)).unwrap();
    let (dir, indexed) = index_with_gix(&pack, hash_kind);

    assert_eq!(indexed.num_objects, 7);
    assert_eq!(indexed.pack_id, outcome.pack_id);

    for id in &ids {
        let (kind, data) = read_from_pack_dir(&dir.objects_dir(), hash_kind, id).expect("readable");
        let (expected_kind, expected_data) = store.raw(id).unwrap();
        assert_eq!(kind, *expected_kind, "object kind survives the round trip");
        assert_eq!(
            &data, expected_data,
            "object {id} must come back byte-exact"
        );
    }

    // …and gix must be able to *parse* what came back, rather than hand back bytes.
    let (_, commit_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &second).unwrap();
    let commit =
        gix_object::CommitRef::from_bytes(&commit_bytes, hash_kind).expect("a parseable commit");
    assert_eq!(commit.tree(), root);
    assert_eq!(commit.parents().collect::<Vec<_>>(), vec![first]);

    let (_, tree_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &root).unwrap();
    let tree = gix_object::TreeRef::from_bytes(&tree_bytes, hash_kind).expect("a parseable tree");
    assert_eq!(tree.entries.len(), 2);
    assert_eq!(tree.entries[0].filename, "README.md");
    assert_eq!(tree.entries[1].oid, subtree);

    let (kind, tag_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &tag).unwrap();
    assert_eq!(
        kind,
        gix_object::Kind::Tag,
        "a tag must not come back as something else"
    );
    let tag_ref = gix_object::TagRef::from_bytes(&tag_bytes, hash_kind).expect("a parseable tag");
    assert_eq!(tag_ref.target(), second);
    assert_eq!(tag_ref.name, "v1.0.0");
}

#[test]
fn trees_and_commits_round_trip_sha1() {
    history_round_trip(Kind::Sha1);
}

#[test]
fn trees_and_commits_round_trip_sha256() {
    history_round_trip(Kind::Sha256);
}

// ─────────────────────────────── empty packs ────────────────────────────────────

#[test]
fn an_empty_pack_is_legal_and_indexes() {
    for hash_kind in [Kind::Sha1, Kind::Sha256] {
        let (pack, outcome) = write_pack_to_vec(
            &[],
            &MemObjects::new(hash_kind),
            &WriteOptions::new(hash_kind),
        )
        .expect("writing an empty pack must not fail");
        assert_eq!(outcome.num_objects, 0);
        assert_eq!(pack, empty_pack(hash_kind).unwrap());

        let (_dir, indexed) = index_with_gix(&pack, hash_kind);
        assert_eq!(
            indexed.num_objects, 0,
            "an empty pack indexes to zero objects"
        );
    }
}

// ─────────────────────────── the guard must be able to fail ─────────────────────

#[test]
fn a_corrupted_pack_is_rejected() {
    let mut store = MemObjects::new(Kind::Sha1);
    let ids = vec![
        store.insert_blob("the quick brown fox jumps over the lazy dog, repeatedly and at length"),
        store.insert_blob("a second object so the first is not the whole pack"),
    ];
    let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();

    // Same bytes, unmodified: this must pass, or the rejection below proves nothing.
    let dir = PackDir::new();
    index_pack(
        &mut Cursor::new(&pack),
        Some(&dir.pack_dir()),
        &IndexOptions::new(Kind::Sha1),
        None,
    )
    .expect("the pristine pack must index");

    // Now flip exactly one bit of one byte, well inside the first object's deflated
    // data, past the 12 byte header and its entry header, far from the trailer.
    let victim = 20;
    assert!(
        victim < pack.len() - 20,
        "the byte must not be in the trailer"
    );
    let mut corrupt = pack.clone();
    corrupt[victim] ^= 0b0000_0001;
    assert_ne!(corrupt, pack);

    let dir = PackDir::new();
    let result = index_pack(
        &mut Cursor::new(&corrupt),
        Some(&dir.pack_dir()),
        &IndexOptions::new(Kind::Sha1),
        None,
    );
    match result {
        Err(Error::Index(_)) => {}
        Err(other) => panic!("expected an indexing rejection, got {other:?}"),
        Ok(indexed) => panic!(
            "a corrupted pack was ACCEPTED with {} objects, so the verification is blind",
            indexed.num_objects
        ),
    }
}

#[test]
fn a_truncated_pack_is_rejected() {
    let mut store = MemObjects::new(Kind::Sha1);
    let ids = vec![store.insert_blob("some content that survives a few hundred bytes of deflate")];
    let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();

    let truncated = &pack[..pack.len() - 5];
    let dir = PackDir::new();
    assert!(
        index_pack(
            &mut Cursor::new(truncated),
            Some(&dir.pack_dir()),
            &IndexOptions::new(Kind::Sha1),
            None,
        )
        .is_err(),
        "a pack missing its trailer must be rejected"
    );
}

// ─────────────────────────────── thin packs ─────────────────────────────────────

#[test]
fn a_thin_pack_resolves_against_the_store() {
    let mut store = MemObjects::new(Kind::Sha1);
    let base_content = b"the base object, which the pusher already knew we had".to_vec();
    let base_id = store.insert_blob(base_content.clone());

    let literal = b"the resolved delta result".to_vec();
    let pack = thin_pack_with_ref_delta(Kind::Sha1, base_id, base_content.len() as u64, &literal);

    // The red half first: without a base lookup, this pack CANNOT be indexed.
    let dir = PackDir::new();
    let without_bases = index_pack(
        &mut Cursor::new(&pack),
        Some(&dir.pack_dir()),
        &IndexOptions::new(Kind::Sha1),
        None,
    );
    assert!(
        without_bases.is_err(),
        "a thin pack must NOT index without its bases, otherwise the thin wiring below is untested"
    );

    // …and the green half: with the store wired in, it resolves.
    let dir = PackDir::new();
    let indexed = index_thin_pack(
        &mut Cursor::new(&pack),
        Some(&dir.pack_dir()),
        &store,
        &IndexOptions::new(Kind::Sha1),
        None,
    )
    .expect("a thin pack must index when its bases are in the store");

    assert_eq!(
        indexed.num_objects, 2,
        "the completed pack holds the delta result and the base pulled in to resolve it"
    );

    let resolved_id =
        gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, &literal).unwrap();
    let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &resolved_id)
        .expect("the delta result must be readable");
    assert_eq!(kind, gix_object::Kind::Blob);
    assert_eq!(
        data, literal,
        "the delta must resolve to exactly its target"
    );

    let (_, base) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &base_id)
        .expect("the completed pack must also carry the base");
    assert_eq!(base, base_content);
}

#[test]
fn a_thin_pack_with_an_unknown_base_is_rejected() {
    let store = MemObjects::new(Kind::Sha1);
    let unknown =
        gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"never stored").unwrap();
    let pack = thin_pack_with_ref_delta(Kind::Sha1, unknown, 12, b"result");

    let dir = PackDir::new();
    assert!(
        index_thin_pack(
            &mut Cursor::new(&pack),
            Some(&dir.pack_dir()),
            &store,
            &IndexOptions::new(Kind::Sha1),
            None,
        )
        .is_err(),
        "a delta whose base is nowhere must be rejected, not silently dropped"
    );
}

// ─────────────────────────────── writer contract ────────────────────────────────

#[test]
fn a_missing_object_is_an_error_not_a_short_pack() {
    let mut store = MemObjects::new(Kind::Sha1);
    let present = store.insert_blob("here");
    let absent = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"not here").unwrap();

    match write_pack_to_vec(&[present, absent], &store, &WriteOptions::new(Kind::Sha1)) {
        Err(Error::ObjectMissing { id }) => assert_eq!(id, absent),
        other => panic!("expected ObjectMissing, got {:?}", other.map(|(_, o)| o)),
    }

    assert_eq!(missing_objects(&[present, absent], &store), vec![absent]);
    assert!(missing_objects(&[present], &store).is_empty());
}

#[test]
fn an_id_of_the_wrong_hash_kind_is_rejected() {
    let mut sha1_store = MemObjects::new(Kind::Sha1);
    let sha1_id = sha1_store.insert_blob("content");

    match write_pack_to_vec(&[sha1_id], &sha1_store, &WriteOptions::new(Kind::Sha256)) {
        Err(Error::HashKindMismatch {
            actual, expected, ..
        }) => {
            assert_eq!(actual, Kind::Sha1);
            assert_eq!(expected, Kind::Sha256);
        }
        other => panic!("expected HashKindMismatch, got {:?}", other.map(|(_, o)| o)),
    }
}

#[test]
fn duplicate_ids_are_written_once() {
    let mut store = MemObjects::new(Kind::Sha1);
    let id = store.insert_blob("only once");

    let (pack, outcome) =
        write_pack_to_vec(&[id, id, id], &store, &WriteOptions::new(Kind::Sha1)).unwrap();
    assert_eq!(outcome.num_objects, 1);

    let (_dir, indexed) = index_with_gix(&pack, Kind::Sha1);
    assert_eq!(indexed.num_objects, 1);
}

#[test]
fn finishing_early_is_an_error_rather_than_a_truncated_pack() {
    let mut store = MemObjects::new(Kind::Sha1);
    let id = store.insert_blob("one");
    let (kind, data) = store.raw(&id).unwrap().clone();

    let mut out = Vec::new();
    let mut writer = PackWriter::new(&mut out, 2, &WriteOptions::new(Kind::Sha1)).unwrap();
    writer.add_object(kind, &data).unwrap();
    assert_eq!(writer.objects_written(), 1);
    assert_eq!(writer.objects_remaining(), 1);

    match writer.finish() {
        Err(Error::ObjectCountMismatch { declared, written }) => {
            assert_eq!((declared, written), (2, 1));
        }
        other => panic!("expected ObjectCountMismatch, got {other:?}"),
    }
}

#[test]
fn writing_past_the_declared_count_is_an_error() {
    let mut store = MemObjects::new(Kind::Sha1);
    let id = store.insert_blob("one");
    let (kind, data) = store.raw(&id).unwrap().clone();

    let mut out = Vec::new();
    let mut writer = PackWriter::new(&mut out, 1, &WriteOptions::new(Kind::Sha1)).unwrap();
    writer.add_object(kind, &data).unwrap();
    assert!(matches!(
        writer.add_object(kind, &data),
        Err(Error::PackFull { declared: 1 })
    ));
}

#[test]
fn compression_level_changes_the_bytes_but_not_the_content() {
    let mut store = MemObjects::new(Kind::Sha1);
    let payload = vec![b'a'; 50_000];
    let id = store.insert_blob(payload.clone());

    let (fast, _) = write_pack_to_vec(
        &[id],
        &store,
        &WriteOptions::new(Kind::Sha1).compression_level(1),
    )
    .unwrap();
    let (best, _) = write_pack_to_vec(
        &[id],
        &store,
        &WriteOptions::new(Kind::Sha1).compression_level(9),
    )
    .unwrap();
    assert_ne!(fast, best, "the deflate level must actually reach flate2");

    for pack in [fast, best] {
        let (dir, indexed) = index_with_gix(&pack, Kind::Sha1);
        assert_eq!(indexed.num_objects, 1);
        let (_, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &id).unwrap();
        assert_eq!(data, payload);
    }
}

#[test]
fn an_allocation_limit_stops_an_oversized_object() {
    // receive-pack's cheap defence: a pushed pack claiming a huge object must be
    // stopped while it is being decoded, not after it has been allocated.
    let mut store = MemObjects::new(Kind::Sha1);
    let ids = vec![store.insert_blob(vec![b'z'; 100_000])];
    let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();

    index_pack(
        &mut Cursor::new(&pack),
        None,
        &IndexOptions::new(Kind::Sha1),
        None,
    )
    .expect("without a limit the same pack indexes fine");

    assert!(
        index_pack(
            &mut Cursor::new(&pack),
            None,
            &IndexOptions::new(Kind::Sha1).alloc_limit_bytes(1_024),
            None,
        )
        .is_err(),
        "an object above the allocation limit must be refused"
    );
}

#[test]
fn validation_only_indexing_keeps_nothing() {
    let mut store = MemObjects::new(Kind::Sha1);
    let ids = vec![store.insert_blob("validate me")];
    let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();

    let indexed = index_pack(
        &mut Cursor::new(&pack),
        None,
        &IndexOptions::new(Kind::Sha1),
        None,
    )
    .expect("validating without a directory must work");
    assert_eq!(indexed.num_objects, 1);
    assert!(
        indexed.data_path.is_none(),
        "nothing is kept when no directory is given"
    );
    assert!(indexed.index_path.is_none());
}