mod common;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::io;
use std::sync::Arc;
use common::{
blob_fixture, commit_fixture, decode_pack, delta_stream, hostile_delta_stream, pack_bytes,
pack_entry_base, pack_entry_delta, tag_fixture, tree_fixture, MemFind, MemObjects, MemPackGen,
MemPackIngest, MemRefs, MemRegistry, SharedSink, ATOMIC_FAILURE_REASON, STALE_REASON,
};
use gix_hash::ObjectId;
use alkgit::{
AccessAction, GitPackGen, GitPackIngest, GitRefs, GitRegistry, GitRegistryStore, Limits,
RefOutcome, RefUpdate, RegistryError, RepoRecord, StorageError, Visibility,
};
fn repo(id: &str) -> RepoRecord {
RepoRecord {
repo_id: id.to_string(),
visibility: Visibility::Private,
grants: BTreeMap::new(),
}
}
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
fn seed(objects: &MemObjects, repo: &RepoRecord, rows: &[(ObjectId, Vec<u8>)]) {
for (id, bytes) in rows {
objects.put(repo, *id, bytes.clone());
}
}
fn object_closure(
objects: &MemObjects,
repo: &RepoRecord,
seeds: &[ObjectId],
) -> BTreeSet<ObjectId> {
let mut closure = BTreeSet::new();
let mut queue: VecDeque<ObjectId> = seeds.iter().copied().collect();
while let Some(oid) = queue.pop_front() {
if !closure.insert(oid) {
continue;
}
let bytes = objects
.find(repo, &oid)
.unwrap_or_else(|| panic!("closure helper resolves the seeded repo: {oid}"));
let (kind, _size, header_len) =
gix_object::decode::loose_header(&bytes).expect("fixture objects decode");
let body = &bytes[header_len..];
match kind {
gix_object::Kind::Blob => {}
gix_object::Kind::Tree => {
let tree = gix_object::TreeRef::from_bytes(body, gix_hash::Kind::Sha1)
.expect("fixture trees decode");
for entry in &tree.entries {
queue.push_back(entry.oid.into());
}
}
gix_object::Kind::Commit => {
let commit = gix_object::CommitRef::from_bytes(body, gix_hash::Kind::Sha1)
.expect("fixture commits decode");
queue.push_back(commit.tree());
for parent in commit.parents() {
queue.push_back(parent);
}
}
gix_object::Kind::Tag => {
let tag = gix_object::TagRef::from_bytes(body, gix_hash::Kind::Sha1)
.expect("fixture tags decode");
queue.push_back(tag.target());
}
}
}
closure
}
#[tokio::test]
async fn unknown_repo_resolve_is_not_found() {
let registry = MemRegistry::default();
let err = registry.resolve("nobody/nothing").await;
assert_eq!(err, Err(RegistryError::NotFound));
}
#[tokio::test]
async fn registry_records_round_trip_put_resolve_update_remove() {
let registry = MemRegistry::default();
registry.put_repo(&repo("owner/doubled")).await.unwrap();
let mut record = repo("owner/doubled");
record.visibility = Visibility::Public;
record
.grants
.insert("u-7731".to_string(), vec![AccessAction::Manage]);
registry.update_repo(&record).await.unwrap();
assert_eq!(registry.resolve("owner/doubled").await.unwrap(), record);
let duplicate = registry.put_repo(&record).await.unwrap_err();
assert_eq!(duplicate, RegistryError::AlreadyExists);
registry.remove_repo("owner/doubled").await.unwrap();
assert_eq!(
registry.resolve("owner/doubled").await,
Err(RegistryError::NotFound)
);
let absent = registry.remove_repo("owner/doubled").await.unwrap_err();
assert_eq!(absent, RegistryError::NotFound);
let absent_update = registry.update_repo(&record).await.unwrap_err();
assert_eq!(absent_update, RegistryError::NotFound);
}
#[tokio::test]
async fn stale_expected_cas_is_a_denied_ref_outcome_not_an_error() {
let refs = MemRefs::default();
let doubled = repo("owner/doubled");
let create = RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(1)),
};
refs.apply_updates(&doubled, vec![create], false)
.await
.unwrap();
let stale_update = RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(2)),
new: Some(oid(3)),
};
let outcomes = refs
.apply_updates(&doubled, vec![stale_update.clone()], false)
.await
.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].name, "refs/heads/main");
assert_eq!(outcomes[0].result, Err(STALE_REASON.to_string()));
let colliding_create = RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(3)),
};
let outcomes = refs
.apply_updates(&doubled, vec![colliding_create], false)
.await
.unwrap();
assert_eq!(outcomes[0].result, Err(STALE_REASON.to_string()));
let stale_delete = RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(9)),
new: None,
};
let outcomes = refs
.apply_updates(&doubled, vec![stale_delete], false)
.await
.unwrap();
assert_eq!(outcomes[0].result, Err(STALE_REASON.to_string()));
let refs_now = refs.list_refs(&doubled).await.unwrap();
assert_eq!(refs_now.len(), 1);
assert_eq!(refs_now[0].oid, Some(oid(1)));
}
#[tokio::test]
async fn non_atomic_updates_apply_each_and_report_per_ref_outcomes() {
let refs = MemRefs::default();
let doubled = repo("owner/doubled");
let seed = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(1)),
}];
refs.apply_updates(&doubled, seed, false).await.unwrap();
let updates = vec![
RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(1)),
new: Some(oid(2)),
},
RefUpdate {
name: "refs/heads/topic".to_string(),
expected: Some(oid(3)),
new: Some(oid(4)),
},
RefUpdate {
name: "refs/stale-away".to_string(),
expected: None,
new: Some(oid(5)),
},
];
let outcomes = refs.apply_updates(&doubled, updates, false).await.unwrap();
assert_eq!(outcomes.len(), 3);
assert_eq!(outcomes[0].result, Ok(()));
assert_eq!(
outcomes[1],
RefOutcome {
name: "refs/heads/topic".to_string(),
result: Err(STALE_REASON.to_string()),
}
);
assert_eq!(outcomes[2].result, Ok(()));
let refs_now = refs.list_refs(&doubled).await.unwrap();
assert_eq!(refs_now.len(), 2);
assert_eq!(refs_now[0].name, "refs/heads/main");
assert_eq!(refs_now[0].oid, Some(oid(2)));
assert_eq!(refs_now[1].name, "refs/stale-away");
assert_eq!(refs_now[1].oid, Some(oid(5)));
}
#[tokio::test]
async fn atomic_denial_rolls_back_the_whole_transaction() {
let refs = MemRefs::default();
let doubled = repo("owner/doubled");
let seed = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(1)),
}];
refs.apply_updates(&doubled, seed, false).await.unwrap();
let updates = vec![
RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(2)),
new: Some(oid(3)),
},
RefUpdate {
name: "refs/heads/topic".to_string(),
expected: None,
new: Some(oid(4)),
},
RefUpdate {
name: "refs/heads/parked".to_string(),
expected: None,
new: Some(oid(5)),
},
];
let outcomes = refs.apply_updates(&doubled, updates, true).await.unwrap();
assert_eq!(outcomes.len(), 3);
assert_eq!(outcomes[0].result, Err(STALE_REASON.to_string()));
assert_eq!(outcomes[1].result, Err(ATOMIC_FAILURE_REASON.to_string()));
assert_eq!(outcomes[2].result, Err(ATOMIC_FAILURE_REASON.to_string()));
let refs_now = refs.list_refs(&doubled).await.unwrap();
assert_eq!(refs_now.len(), 1);
assert_eq!(refs_now[0].oid, Some(oid(1)));
let updates = vec![
RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(1)),
new: None,
},
RefUpdate {
name: "refs/heads/topic".to_string(),
expected: None,
new: Some(oid(4)),
},
];
let outcomes = refs.apply_updates(&doubled, updates, true).await.unwrap();
assert!(outcomes.iter().all(|outcome| outcome.result.is_ok()));
let refs_now = refs.list_refs(&doubled).await.unwrap();
assert_eq!(refs_now.len(), 1);
assert_eq!(refs_now[0].name, "refs/heads/topic");
}
#[tokio::test]
async fn common_haves_recognizes_a_have_iff_it_exists_and_is_a_commit() {
let doubled = repo("owner/doubled");
let cousin = repo("other/doubled");
let objects = MemObjects::default();
let (blob, blob_bytes) = blob_fixture(b"leaf");
let (tree, tree_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob)]);
let (commit, commit_bytes) = commit_fixture(tree, &[], "a commit");
seed(
&objects,
&doubled,
&[
(blob, blob_bytes.clone()),
(tree, tree_bytes),
(commit, commit_bytes),
(oid(1), vec![1, 2, 3]),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let unrecognized = gen.common_haves(&doubled, vec![blob, tree]).await.unwrap();
assert!(unrecognized.is_empty(), "non-commit objects are not common");
let malformed = gen.common_haves(&doubled, vec![oid(1)]).await.unwrap();
assert!(
malformed.is_empty(),
"unrecognizable stored bytes fail closed"
);
let missing = gen
.common_haves(&doubled, vec![oid(2), oid(3)])
.await
.unwrap();
assert!(missing.is_empty(), "absent oids are not common");
let foreign = gen.common_haves(&cousin, vec![commit]).await.unwrap();
assert!(foreign.is_empty(), "a foreign namespace is not common");
let common = gen
.common_haves(&doubled, vec![oid(2), commit, oid(3), commit])
.await
.unwrap();
assert_eq!(
common,
vec![commit, commit],
"order and duplicates verbatim"
);
assert_eq!(
objects.find(&doubled, &blob),
Some(blob_bytes),
"the Find-shaped object lookup resolves the stored bytes"
);
assert_eq!(objects.find(&cousin, &blob), None);
}
#[tokio::test]
async fn doubles_compile_against_the_pinned_trait_surface() {
let registry: Arc<dyn GitRegistry> = Arc::new(MemRegistry::default());
let store: Arc<dyn GitRegistryStore> = Arc::new(MemRegistry::default());
let refs: Arc<dyn GitRefs> = Arc::new(MemRefs::default());
let gen = MemPackGen::default();
let gen: Arc<MemPackGen> = Arc::new(gen);
let ingest_double = MemPackIngest::default();
let ingest: Arc<dyn GitPackIngest> = Arc::new(ingest_double.clone());
let doubled = repo("owner/doubled");
assert_eq!(
registry.resolve("owner/doubled").await.unwrap_err(),
RegistryError::NotFound
);
store.put_repo(&doubled).await.unwrap();
assert!(store.update_repo(&doubled).await.is_ok());
assert!(store.remove_repo("owner/doubled").await.is_ok());
assert!(refs.list_refs(&doubled).await.unwrap().is_empty());
let generate = gen
.generate(
&doubled,
vec![oid(1)],
Vec::new(),
Limits::default(),
Box::new(std::io::sink()),
)
.await
.unwrap_err();
assert_eq!(generate, StorageError::ObjectMissing { oid: oid(1) });
let empty_pack = pack_bytes(&[]);
let prepared = ingest
.prepare(
&doubled,
Vec::new(),
Box::new(io::Cursor::new(empty_pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the zero-object pack is a valid ingestion: {:?}",
prepared.unpack
);
assert!(prepared.updates.is_empty());
assert!(!ingest_double.objects().contains(&doubled, &oid(1)));
}
#[tokio::test]
async fn generate_produces_the_full_wants_closure_as_a_valid_pack() {
let doubled = repo("owner/doubled");
let (blob1, blob1_bytes) = blob_fixture(b"one");
let (blob2, blob2_bytes) = blob_fixture(b"two");
let (blob_lone, blob_lone_bytes) = blob_fixture(b"lone");
let (tree1, tree1_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob1)]);
let (tree2, tree2_bytes) = tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "a.txt", blob1),
(gix_object::tree::EntryKind::Blob, "b.txt", blob2),
]);
let (commit1, commit1_bytes) = commit_fixture(tree1, &[], "one");
let (commit2, commit2_bytes) = commit_fixture(tree2, &[commit1], "two");
let (tag, tag_bytes) = tag_fixture(commit2, gix_object::Kind::Commit, "v1.0");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob1, blob1_bytes),
(blob2, blob2_bytes),
(blob_lone, blob_lone_bytes),
(tree1, tree1_bytes),
(tree2, tree2_bytes),
(commit1, commit1_bytes),
(commit2, commit2_bytes),
(tag, tag_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
gen.generate(
&doubled,
vec![commit2, tag, blob_lone],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap();
let pack = decode_pack(&sink.snapshot());
assert_eq!(pack.version, gix_pack::data::Version::V2);
let ids: BTreeSet<ObjectId> = pack.objects.iter().map(|(id, _, _)| *id).collect();
let expected = object_closure(&objects, &doubled, &[commit2, tag, blob_lone]);
assert_eq!(ids, expected);
assert_eq!(pack.object_count as usize, pack.objects.len());
assert_eq!(
pack.objects.len(),
pack.objects
.iter()
.map(|(id, _, _)| *id)
.collect::<BTreeSet<_>>()
.len(),
"closure members ship once"
);
let _ = pack.trailer;
}
#[tokio::test]
async fn repeated_generate_calls_regenerate_byte_identical_packs() {
let doubled = repo("owner/doubled");
let (blob1, blob1_bytes) = blob_fixture(b"one");
let (tree1, tree1_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob1)]);
let (commit1, commit1_bytes) = commit_fixture(tree1, &[], "one");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob1, blob1_bytes),
(tree1, tree1_bytes),
(commit1, commit1_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let first = SharedSink::default();
gen.generate(
&doubled,
vec![commit1],
Vec::new(),
Limits::default(),
Box::new(first.clone()),
)
.await
.unwrap();
let second = SharedSink::default();
gen.generate(
&doubled,
vec![commit1],
Vec::new(),
Limits::default(),
Box::new(second.clone()),
)
.await
.unwrap();
assert_eq!(first.snapshot(), second.snapshot());
}
#[tokio::test]
async fn generate_subtracts_the_boundary_haves_ancestry() {
let doubled = repo("owner/doubled");
let (blob_a, blob_a_bytes) = blob_fixture(b"alpha");
let (blob_b, blob_b_bytes) = blob_fixture(b"beta");
let (tree_a, tree_a_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob_a)]);
let (tree_b, tree_b_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "b.txt", blob_b)]);
let (commit_a, commit_a_bytes) = commit_fixture(tree_a, &[], "alpha");
let (commit_b, commit_b_bytes) = commit_fixture(tree_b, &[commit_a], "beta");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob_a, blob_a_bytes),
(blob_b, blob_b_bytes),
(tree_a, tree_a_bytes),
(tree_b, tree_b_bytes),
(commit_a, commit_a_bytes),
(commit_b, commit_b_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
gen.generate(
&doubled,
vec![commit_b],
vec![commit_a],
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap();
let pack = decode_pack(&sink.snapshot());
let ids: BTreeSet<ObjectId> = pack.objects.iter().map(|(id, _, _)| *id).collect();
let expected = object_closure(&objects, &doubled, &[commit_b]);
let haves = object_closure(&objects, &doubled, &[commit_a]);
let subtracted: BTreeSet<ObjectId> = expected.difference(&haves).copied().collect();
assert_eq!(ids, subtracted);
assert!(!ids.contains(&commit_a));
assert!(!ids.contains(&tree_a));
}
#[tokio::test]
async fn generate_ships_a_peeled_tag_without_its_common_target_commit() {
let doubled = repo("owner/doubled");
let (blob_c, blob_c_bytes) = blob_fixture(b"charlie");
let (tree_c, tree_c_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "c.txt", blob_c)]);
let (commit_c, commit_c_bytes) = commit_fixture(tree_c, &[], "charlie");
let (tag, tag_bytes) = tag_fixture(commit_c, gix_object::Kind::Commit, "v1");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob_c, blob_c_bytes),
(tree_c, tree_c_bytes),
(commit_c, commit_c_bytes),
(tag, tag_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
gen.generate(
&doubled,
vec![tag],
vec![commit_c],
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap();
let pack = decode_pack(&sink.snapshot());
let ids: BTreeSet<ObjectId> = pack.objects.iter().map(|(id, _, _)| *id).collect();
assert_eq!(ids, BTreeSet::from([tag]));
}
#[tokio::test]
async fn all_boundary_wants_generate_a_valid_zero_object_pack() {
let doubled = repo("owner/doubled");
let (blob_d, blob_d_bytes) = blob_fixture(b"delta");
let (tree_d, tree_d_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "d.txt", blob_d)]);
let (commit_d, commit_d_bytes) = commit_fixture(tree_d, &[], "delta");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob_d, blob_d_bytes),
(tree_d, tree_d_bytes),
(commit_d, commit_d_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
gen.generate(
&doubled,
vec![commit_d],
vec![commit_d],
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap();
let pack = decode_pack(&sink.snapshot());
assert_eq!(pack.version, gix_pack::data::Version::V2);
assert_eq!(pack.object_count, 0);
assert!(pack.objects.is_empty());
}
#[tokio::test]
async fn generate_aborts_on_a_missing_want_from_the_repos_own_namespace() {
let doubled = repo("owner/doubled");
let cousin = repo("other/doubled");
let (blob_x, blob_x_bytes) = blob_fixture(b"x");
let objects = MemObjects::default();
seed(&objects, &cousin, &[(blob_x, blob_x_bytes)]);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
let missing = gen
.generate(
&doubled,
vec![blob_x],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap_err();
assert_eq!(missing, StorageError::ObjectMissing { oid: blob_x });
assert!(sink.snapshot().is_empty());
}
#[tokio::test]
async fn missing_commit_ancestry_aborts_with_the_missing_oid() {
let doubled = repo("owner/doubled");
let (blob_e, blob_e_bytes) = blob_fixture(b"echo");
let (tree_e, tree_e_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "e.txt", blob_e)]);
let (commit_e, commit_e_bytes) = commit_fixture(tree_e, &[], "echo");
let missing_parent = oid(9);
let (commit_f, commit_f_bytes) = commit_fixture(tree_e, &[missing_parent], "foxtrot");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob_e, blob_e_bytes),
(tree_e, tree_e_bytes),
(commit_e, commit_e_bytes),
(commit_f, commit_f_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
let missing = gen
.generate(
&doubled,
vec![commit_f],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap_err();
assert_eq!(
missing,
StorageError::ObjectMissing {
oid: missing_parent
}
);
assert!(sink.snapshot().is_empty());
}
#[tokio::test]
async fn missing_leaf_closure_object_aborts_before_any_pack_byte() {
let doubled = repo("owner/doubled");
let (blob_g, blob_g_bytes) = blob_fixture(b"golf");
let missing_blob = oid(3);
let (tree_h, tree_h_bytes) = tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "g.txt", blob_g),
(gix_object::tree::EntryKind::Blob, "h.txt", missing_blob),
]);
let (commit_h, commit_h_bytes) = commit_fixture(tree_h, &[], "hotel");
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[
(blob_g, blob_g_bytes),
(tree_h, tree_h_bytes),
(commit_h, commit_h_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
let missing = gen
.generate(
&doubled,
vec![commit_h],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap_err();
assert_eq!(missing, StorageError::ObjectMissing { oid: missing_blob });
assert!(sink.snapshot().is_empty());
}
#[tokio::test]
async fn unborn_head_symref_row_appears_in_list_refs_and_clears() {
let refs = MemRefs::default();
let doubled = repo("owner/doubled");
refs.set_head_symref(&doubled, Some("refs/heads/main".to_string()));
let listed = refs.list_refs(&doubled).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "HEAD");
assert_eq!(listed[0].oid, None);
assert_eq!(listed[0].symref_target.as_deref(), Some("refs/heads/main"));
refs.apply_updates(
&doubled,
vec![RefUpdate {
name: "refs/heads/topic".to_string(),
expected: None,
new: Some(oid(6)),
}],
false,
)
.await
.unwrap();
let listed = refs.list_refs(&doubled).await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].name, "HEAD", "the HEAD row lists first");
assert_eq!(listed[1].name, "refs/heads/topic");
refs.set_head_symref(&doubled, None);
let listed = refs.list_refs(&doubled).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "refs/heads/topic");
}
#[test]
fn memfind_decodes_the_stored_loose_object_encoding() {
let doubled = repo("owner/doubled");
let content = b"a blob with body";
let (blob, blob_bytes) = blob_fixture(content);
let (tree, tree_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "x.txt", blob)]);
let objects = MemObjects::default();
seed(
&objects,
&doubled,
&[(blob, blob_bytes), (tree, tree_bytes)],
);
let find = MemFind::new(&objects, &doubled);
assert!(gix_object::Exists::exists(&find, &blob));
assert!(gix_object::Exists::exists(&find, &tree));
assert!(!gix_object::Exists::exists(&find, &oid(7)));
let mut buffer = Vec::new();
let data = gix_object::Find::try_find(&find, &blob, &mut buffer)
.expect("the blob lookup succeeds")
.expect("the blob resolves");
assert_eq!(data.kind, gix_object::Kind::Blob);
assert_eq!(data.data, content);
}
fn loose_body(bytes: &[u8]) -> (gix_object::Kind, Vec<u8>) {
let (kind, _size, header_len) =
gix_object::decode::loose_header(bytes).expect("fixture loose bytes decode");
(kind, bytes[header_len..].to_vec())
}
fn delta_product(base_body: &[u8], suffix: &[u8]) -> (ObjectId, Vec<u8>) {
let mut body = base_body.to_vec();
body.extend_from_slice(suffix);
let id = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, &body)
.expect("the delta product hashes");
(id, body)
}
#[tokio::test]
async fn self_contained_pack_ingests_as_loose_rows_keyed_by_oid() {
let source = repo("owner/source");
let doubled = repo("owner/doubled");
let (blob1, blob1_bytes) = blob_fixture(b"one");
let (blob2, blob2_bytes) = blob_fixture(b"two");
let (tree1, tree1_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob1)]);
let (tree2, tree2_bytes) = tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "a.txt", blob1),
(gix_object::tree::EntryKind::Blob, "b.txt", blob2),
]);
let (commit1, commit1_bytes) = commit_fixture(tree1, &[], "one");
let (commit2, commit2_bytes) = commit_fixture(tree2, &[commit1], "two");
let (tag, tag_bytes) = tag_fixture(commit2, gix_object::Kind::Commit, "v1.0");
let objects = MemObjects::default();
seed(
&objects,
&source,
&[
(blob1, blob1_bytes),
(blob2, blob2_bytes),
(tree1, tree1_bytes),
(tree2, tree2_bytes),
(commit1, commit1_bytes),
(commit2, commit2_bytes),
(tag, tag_bytes),
],
);
let gen = MemPackGen {
objects: objects.clone(),
};
let sink = SharedSink::default();
gen.generate(
&source,
vec![commit2, tag],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.unwrap();
let pack = sink.snapshot();
let ingest = MemPackIngest::default();
let updates = vec![
RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(commit2),
},
RefUpdate {
name: "refs/tags/v1".to_string(),
expected: None,
new: Some(tag),
},
];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack.clone())),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the self-contained pack ingests: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, updates, "the updates round-trip verbatim");
let decoded = decode_pack(&pack);
let landed: BTreeSet<ObjectId> = decoded
.objects
.iter()
.map(|(id, kind, _)| {
let stored = ingest
.objects()
.find(&doubled, id)
.unwrap_or_else(|| panic!("the pack object {id} landed"));
let (stored_kind, _size, header_len) =
gix_object::decode::loose_header(&stored).expect("the row decodes");
assert_eq!(&stored_kind, kind, "the loose row keeps the object kind");
let rehashed =
gix_object::compute_hash(gix_hash::Kind::Sha1, stored_kind, &stored[header_len..])
.expect("the row re-hashes on put");
assert_eq!(&rehashed, id, "the loose row is keyed by its own oid");
*id
})
.collect();
assert_eq!(
landed,
object_closure(ingest.objects(), &doubled, &[commit2, tag]),
"the namespace closure from the pushed tips is the pack's object set"
);
}
#[tokio::test]
async fn thin_pack_bases_resolve_from_the_namespace_and_missing_bases_fail_into_unpack_ng() {
let doubled = repo("owner/doubled");
let (blob_server, blob_server_bytes) = blob_fixture(b"server-base");
let (tree_server, tree_server_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob_server)]);
let (commit_server, commit_server_bytes) = commit_fixture(tree_server, &[], "base");
let objects = MemObjects::default();
let ingest = MemPackIngest {
objects: objects.clone(),
};
seed(
&objects,
&doubled,
&[
(blob_server, blob_server_bytes.clone()),
(tree_server, tree_server_bytes),
(commit_server, commit_server_bytes),
],
);
let (_blob_base_kind, blob_base_body) = loose_body(&blob_server_bytes);
let (blob_new, _blob_new_body) = delta_product(&blob_base_body, b"-patched");
let (commit_new, commit_new_bytes) = commit_fixture(tree_server, &[commit_server], "thin push");
let commit_body = loose_body(&commit_new_bytes).1;
let base_entry = pack_entry_base(gix_object::Kind::Commit, &commit_body, 0);
let delta_entry = pack_entry_delta(
gix_pack::data::entry::Header::RefDelta {
base_id: blob_server,
},
&delta_stream(blob_base_body.len() as u64, b"-patched"),
base_entry.bytes_in_pack(),
);
let thin_pack = pack_bytes(&[delta_entry, base_entry]);
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(commit_server),
new: Some(commit_new),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(thin_pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the thin pack's bases resolve from the namespace: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, updates);
assert!(ingest.objects().contains(&doubled, &blob_new));
assert!(ingest.objects().contains(&doubled, &commit_new));
let (orphan, _orphan_body) = delta_product(&blob_base_body, b"-orphaned");
let missing_base_pack = pack_bytes(&[pack_entry_delta(
gix_pack::data::entry::Header::RefDelta { base_id: oid(11) },
&delta_stream(blob_base_body.len() as u64, b"-orphaned"),
0,
)]);
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(missing_base_pack)),
None,
Limits::default(),
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("a missing thin base fails into the unpack ng leg");
assert!(!reason.is_empty(), "the ng reason is server-chosen");
assert_eq!(prepared.updates, updates, "verbatim on the ng path too");
assert!(
!ingest.objects().contains(&doubled, &orphan),
"a failed ingestion lands nothing"
);
}
#[tokio::test]
async fn in_pack_ofs_deltas_expand_and_land_with_their_bases() {
let doubled = repo("owner/doubled");
let (blob_a, blob_a_bytes) = blob_fixture(b"alpha");
let (tree_a, tree_a_bytes) =
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob_a)]);
let (commit_a, commit_a_bytes) = commit_fixture(tree_a, &[], "alpha");
let objects = MemObjects::default();
let ingest = MemPackIngest { objects };
seed(
ingest.objects(),
&doubled,
&[
(blob_a, blob_a_bytes.clone()),
(tree_a, tree_a_bytes),
(commit_a, commit_a_bytes),
],
);
let (_blob_a_kind, blob_a_body) = loose_body(&blob_a_bytes);
let (blob_b, _blob_b_body) = delta_product(&blob_a_body, b"-beta");
let (tree_b, tree_b_bytes) = tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "a.txt", blob_a),
(gix_object::tree::EntryKind::Blob, "b.txt", blob_b),
]);
let (commit_b, commit_b_bytes) = commit_fixture(tree_b, &[commit_a], "beta");
let base_entry = pack_entry_base(gix_object::Kind::Blob, &blob_a_body, 0);
let delta_entry = pack_entry_delta(
gix_pack::data::entry::Header::OfsDelta {
base_distance: base_entry.bytes_in_pack(),
},
&delta_stream(blob_a_body.len() as u64, b"-beta"),
base_entry.bytes_in_pack(),
);
let tree_body = loose_body(&tree_b_bytes).1;
let tree_entry = pack_entry_base(
gix_object::Kind::Tree,
&tree_body,
base_entry.bytes_in_pack() + delta_entry.bytes_in_pack(),
);
let commit_body = loose_body(&commit_b_bytes).1;
let commit_entry = pack_entry_base(
gix_object::Kind::Commit,
&commit_body,
base_entry.bytes_in_pack() + delta_entry.bytes_in_pack() + tree_entry.bytes_in_pack(),
);
let pack = pack_bytes(&[base_entry, delta_entry, tree_entry, commit_entry]);
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(commit_a),
new: Some(commit_b),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the ofs-delta pack ingests: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, updates);
let ingested = ingest.objects();
for oid in [blob_b, tree_b, commit_b] {
assert!(ingested.contains(&doubled, &oid), "{oid} landed");
}
assert_eq!(
object_closure(ingested, &doubled, &[commit_b]),
BTreeSet::from([blob_a, blob_b, tree_a, tree_b, commit_a, commit_b]),
"the combined-graph walk closes over the pack and the namespace"
);
}
#[tokio::test]
async fn forward_ref_deltas_resolve_after_their_in_pack_base() {
let doubled = repo("owner/doubled");
let ingest = MemPackIngest::default();
let (blob_base, blob_base_bytes) = blob_fixture(b"forward-base");
let (_blob_base_kind, blob_base_body) = loose_body(&blob_base_bytes);
let (blob_new, _blob_new_body) = delta_product(&blob_base_body, b"-forwarded");
let delta_entry = pack_entry_delta(
gix_pack::data::entry::Header::RefDelta { base_id: blob_base },
&delta_stream(blob_base_body.len() as u64, b"-forwarded"),
0,
);
let base_entry = pack_entry_base(
gix_object::Kind::Blob,
&blob_base_body,
delta_entry.bytes_in_pack(),
);
let pack = pack_bytes(&[delta_entry, base_entry]);
let updates = vec![RefUpdate {
name: "refs/heads/forward".to_string(),
expected: None,
new: Some(blob_new),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the forward ref-delta resolves once its base stages: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, updates);
assert!(ingest.objects().contains(&doubled, &blob_base));
assert!(ingest.objects().contains(&doubled, &blob_new));
}
#[tokio::test]
async fn connectivity_walk_from_pushed_tips_includes_parents() {
let doubled = repo("owner/doubled");
let (blob, blob_bytes) = blob_fixture(b"connected");
let (tree, tree_bytes) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob)]);
let (commit_child, commit_child_bytes) = commit_fixture(tree, &[oid(9)], "orphan child");
let objects = MemObjects::default();
let ingest = MemPackIngest { objects };
seed(
ingest.objects(),
&doubled,
&[
(blob, blob_bytes),
(tree, tree_bytes),
(commit_child, commit_child_bytes.clone()),
],
);
let missing_parent_pack = pack_bytes(&[pack_entry_base(
gix_object::Kind::Commit,
&loose_body(&commit_child_bytes).1,
0,
)]);
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(commit_child),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(missing_parent_pack)),
None,
Limits::default(),
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("a missing parent fails the walk into the unpack ng leg");
assert!(
reason.contains("missing object"),
"the reason names the miss"
);
assert_eq!(prepared.updates, updates, "verbatim on the ng path too");
let (commit_seed, commit_seed_bytes) = commit_fixture(tree, &[], "seeded");
ingest
.objects()
.put(&doubled, commit_seed, commit_seed_bytes);
let empty_pack = pack_bytes(&[]);
let existing_tip = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: Some(oid(9)),
new: Some(commit_seed),
}];
let prepared = ingest
.prepare(
&doubled,
existing_tip.clone(),
Box::new(io::Cursor::new(empty_pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"a tip that already exists server-side passes over an empty pack: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, existing_tip);
}
#[tokio::test]
async fn empty_pack_is_valid_and_reports_unpack_ok() {
let doubled = repo("owner/doubled");
let ingest = MemPackIngest::default();
let updates = vec![RefUpdate {
name: "refs/heads/untouched".to_string(),
expected: None,
new: None,
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack_bytes(&[]))),
None,
Limits::default(),
)
.await
.unwrap();
assert!(
prepared.unpack.is_ok(),
"the empty pack is valid: {:?}",
prepared.unpack
);
assert_eq!(prepared.updates, updates);
}
#[tokio::test]
async fn pack_stream_exceeding_max_pack_size_fails_into_unpack_ng() {
let doubled = repo("owner/doubled");
let (_blob, blob_bytes) = blob_fixture(b"budget-breach");
let ingest = MemPackIngest::default();
let oversized_pack = pack_bytes(&[pack_entry_base(
gix_object::Kind::Blob,
&loose_body(&blob_bytes).1,
0,
)]);
let limits = Limits {
max_pack_size: 16,
..Limits::default()
};
assert!(
oversized_pack.len() > 16,
"the fixture pack stream exceeds the test budget"
);
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(4)),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(oversized_pack)),
None,
limits,
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the budget breach fails into the unpack ng leg");
assert!(!reason.is_empty());
assert_eq!(prepared.updates, updates, "verbatim on the ng path too");
assert!(!ingest.objects().contains(&doubled, &oid(4)));
}
#[tokio::test]
async fn corrupt_and_truncated_packs_fail_into_unpack_ng_and_err_is_unreachable() {
let doubled = repo("owner/doubled");
let (_blob, blob_bytes) = blob_fixture(b"truncation-fodder");
let ingest = MemPackIngest {
objects: MemObjects::default(),
};
let pack = pack_bytes(&[pack_entry_base(
gix_object::Kind::Blob,
&loose_body(&blob_bytes).1,
0,
)]);
let truncated = pack[..pack.len() - 4].to_vec();
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(5)),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(truncated)),
None,
Limits::default(),
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the truncated pack fails into the unpack ng leg");
assert!(!reason.is_empty());
assert_eq!(prepared.updates, updates);
let mut corrupt = pack.clone();
let corrupt_at = gix_pack::data::header::SIZE + 2;
corrupt[corrupt_at] ^= 0x40;
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(corrupt)),
None,
Limits::default(),
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the corrupt pack fails into the unpack ng leg");
assert!(!reason.is_empty());
assert_eq!(prepared.updates, updates);
assert!(!ingest.objects().contains(&doubled, &oid(5)));
}
#[tokio::test]
async fn a_decompressed_size_beyond_the_ingestion_cap_fails_into_unpack_ng() {
let doubled = repo("owner/doubled");
let body = vec![0u8; 100_000];
let (expected, _expected_bytes) = blob_fixture(&body);
let ingest = MemPackIngest::default();
let pack = pack_bytes(&[pack_entry_base(gix_object::Kind::Blob, &body, 0)]);
let limits = Limits {
max_object_size: 65536,
..Limits::default()
};
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(expected),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack)),
None,
limits,
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the expansion beyond the cap fails into the unpack ng leg");
assert!(
reason.contains("ingestion cap"),
"the reason names the decompressed-size cap: {reason}"
);
assert!(!ingest.objects().contains(&doubled, &expected));
assert_eq!(prepared.updates, updates, "verbatim on the ng path too");
}
#[tokio::test]
async fn a_hostile_declared_decompressed_size_fails_into_unpack_ng() {
let doubled = repo("owner/doubled");
let ingest = MemPackIngest::default();
let mut hostile = pack_entry_base(gix_object::Kind::Blob, b"", 0);
hostile.decompressed_size = 1u64 << 40;
let pack = pack_bytes(&[hostile]);
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(6)),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack)),
None,
Limits::default(),
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the hostile declaration fails into the unpack ng leg");
assert!(!reason.is_empty(), "the ng reason is server-chosen");
assert!(!ingest.objects().contains(&doubled, &oid(6)));
assert_eq!(prepared.updates, updates, "verbatim on the ng path too");
}
#[tokio::test]
async fn a_delta_declaring_a_result_beyond_the_cap_fails_into_unpack_ng() {
let doubled = repo("owner/doubled");
let (_blob_base, blob_base_bytes) = blob_fixture(b"delta-base");
let (_blob_base_kind, blob_base_body) = loose_body(&blob_base_bytes);
let base_entry = pack_entry_base(gix_object::Kind::Blob, &blob_base_body, 0);
let delta_entry = pack_entry_delta(
gix_pack::data::entry::Header::OfsDelta {
base_distance: base_entry.bytes_in_pack(),
},
&hostile_delta_stream(blob_base_body.len() as u64, 200_000),
base_entry.bytes_in_pack(),
);
let ingest = MemPackIngest {
objects: MemObjects::default(),
};
let pack = pack_bytes(&[base_entry, delta_entry]);
let limits = Limits {
max_object_size: 65536,
..Limits::default()
};
let updates = vec![RefUpdate {
name: "refs/heads/main".to_string(),
expected: None,
new: Some(oid(7)),
}];
let prepared = ingest
.prepare(
&doubled,
updates.clone(),
Box::new(io::Cursor::new(pack)),
None,
limits,
)
.await
.unwrap();
let reason = prepared
.unpack
.clone()
.expect_err("the hostile delta result fails into the unpack ng leg");
assert!(
reason.contains("ingestion cap"),
"the reason names the decompressed-size cap: {reason}"
);
assert!(!ingest.objects().contains(&doubled, &oid(7)));
}