use std::io::{Read as _, Write as _};
use mkit_core::hash::{self, Hash};
use mkit_core::object::Object;
use mkit_core::pack::{self, PackReader};
use mkit_core::protocol::{AdvanceOutcome, CommitOutcome, PackKey, Transport, TransportError};
use mkit_core::refs;
use mkit_core::sign;
use mkit_core::store::ObjectStore;
use mkit_core::transfer;
use rayon::prelude::*;
use super::DispatchError;
use super::applied_packs::AppliedPacks;
pub(crate) fn packmap_ref(branch: &str) -> String {
format!("refs/mkit/packmap/{branch}")
}
fn branch_ref_hint(branch: &str) -> String {
format!(
"refs/heads/{}",
branch.strip_prefix("refs/heads/").unwrap_or(branch)
)
}
const PACKMAP_CAS_ATTEMPTS: u32 = 8;
const MAX_PACK_CHAIN_DEPTH: usize = 100_000;
const MAX_FETCH_PACKS: usize = 1_000_000;
const MAX_FETCH_STAGED_BYTES: u64 = 64 * 1024 * 1024 * 1024;
const DEFAULT_REBASELINE_DEPTH: usize = 64;
pub(crate) fn rebaseline_depth() -> usize {
match std::env::var("MKIT_PACK_REBASELINE_DEPTH") {
Err(_) => DEFAULT_REBASELINE_DEPTH,
Ok(s) => s.parse::<usize>().unwrap_or_else(|_| {
eprintln!(
"warning: MKIT_PACK_REBASELINE_DEPTH='{s}' is not a valid non-negative \
integer; using the default {DEFAULT_REBASELINE_DEPTH} (set it to 0 to \
disable re-baselining)"
);
DEFAULT_REBASELINE_DEPTH
}),
}
}
fn download_packlist_node(
tx: &dyn Transport,
key: Hash,
ref_name: &str,
) -> Result<transfer::PackListNode, DispatchError> {
let requested = PackKey::from_hash(key);
let bytes = tx.download_blob_via_ref(&requested, ref_name)?;
requested.verify_bytes(&bytes)?;
Ok(transfer::decode_packlist(&bytes)?)
}
fn walk_pack_chain(
tx: &dyn Transport,
branch: &str,
head_key: Hash,
) -> Result<Vec<transfer::PackListNode>, DispatchError> {
let invalid = || DispatchError::PackChainInvalid {
branch: branch.to_owned(),
};
let mut nodes = Vec::new();
let mut pack_count = 0usize;
let mut seen = std::collections::HashSet::new();
let mut cursor = Some(head_key);
let ref_name = branch_ref_hint(branch);
while let Some(key) = cursor {
if crate::signal::is_shutdown() {
return Err(DispatchError::Interrupted);
}
if !seen.insert(key) || seen.len() > MAX_PACK_CHAIN_DEPTH {
return Err(invalid());
}
let node = match download_packlist_node(tx, key, &ref_name) {
Ok(n) => n,
Err(
DispatchError::Transport(
TransportError::PackNotFound | TransportError::InvalidResponse,
)
| DispatchError::PackList(_),
) => return Err(invalid()),
Err(e) => return Err(e),
};
pack_count = pack_count
.checked_add(node.packs.len())
.ok_or_else(invalid)?;
if pack_count > MAX_FETCH_PACKS {
return Err(invalid());
}
cursor = node.prev;
nodes.push(node);
}
Ok(nodes)
}
pub(crate) fn resolve_pack_chain(
tx: &dyn Transport,
branch: &str,
head_key: Hash,
) -> Result<Vec<Hash>, DispatchError> {
Ok(probe_chain(tx, branch, head_key)?.packs)
}
#[derive(Debug)]
pub(crate) struct ResolvedChain {
pub(crate) head: Hash,
pub(crate) depth: usize,
pub(crate) packs: Vec<Hash>,
}
pub(crate) fn probe_chain(
tx: &dyn Transport,
branch: &str,
head_key: Hash,
) -> Result<ResolvedChain, DispatchError> {
let mut nodes = walk_pack_chain(tx, branch, head_key)?;
let depth = nodes.len();
nodes.reverse(); let packs = nodes.into_iter().flat_map(|n| n.packs).collect();
Ok(ResolvedChain {
head: head_key,
depth,
packs,
})
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ChainAction {
Append {
self_contained: bool,
},
ResetSelfContained,
}
pub(crate) fn advance_packmap(
tx: &dyn Transport,
branch: &str,
pack_keys: &[Hash],
action: ChainAction,
resolved: Option<ResolvedChain>,
head_condition: refs::RefWriteCondition,
tip: Hash,
) -> Result<(), DispatchError> {
debug_assert!(
!pack_keys.is_empty(),
"advance_packmap requires at least one pack key — callers only reach this with a \
non-empty plan"
);
let packmap_name = packmap_ref(branch);
let head_name = format!("refs/heads/{branch}");
let mut cached = resolved;
for _ in 0..PACKMAP_CAS_ATTEMPTS {
if crate::signal::is_shutdown() {
return Err(DispatchError::Interrupted);
}
let prior = tx.read_ref(&packmap_name)?;
let prev = match action {
ChainAction::ResetSelfContained => {
debug_assert!(
tx.supports_atomic_advance(),
"re-baseline reset requires a transactional advance_refs (mkit #521)"
);
debug_assert!(
!matches!(head_condition, refs::RefWriteCondition::Any),
"re-baseline reset must not run with an `Any` head condition — the \
ordered advance_refs fallback would strand the head (mkit #521)"
);
None
}
ChainAction::Append { self_contained } => match prior {
None => None,
Some(p) => {
let packs = match cached.take() {
Some(c) if c.head == p => Ok(c.packs),
_ => resolve_pack_chain(tx, branch, p),
};
match packs {
Ok(packs) => {
let have: std::collections::HashSet<&Hash> = packs.iter().collect();
if pack_keys.iter().all(|k| have.contains(k)) {
return commit_head(tx, &head_name, head_condition, &tip, branch);
}
Some(p) }
Err(DispatchError::PackChainInvalid { .. }) if self_contained => None,
Err(e) => return Err(e),
}
}
},
};
let node = transfer::encode_packlist(prev, pack_keys)?;
let node_key = pack::pack_key(&node);
tx.upload_blob_via_ref(&node, &PackKey::from_hash(node_key), &head_name)
.map_err(|error| super::repository_operation_error(tx, error))?;
let packmap_condition = match prior {
Some(k) => refs::RefWriteCondition::Match(k),
None => refs::RefWriteCondition::Missing,
};
let commit_keys: Vec<PackKey> = pack_keys
.iter()
.copied()
.map(PackKey::from_hash)
.chain(std::iter::once(PackKey::from_hash(node_key)))
.collect();
match tx
.advance_refs_committing(
&head_name,
head_condition,
&tip,
&packmap_name,
packmap_condition,
&node_key,
&commit_keys,
)
.map_err(|error| super::repository_operation_error(tx, error))?
{
CommitOutcome::Advanced(AdvanceOutcome::Committed) => return Ok(()),
CommitOutcome::Advanced(AdvanceOutcome::PackmapConflict) => {}
CommitOutcome::Advanced(AdvanceOutcome::HeadConflict) => {
return head_conflict(tx, &head_name, &tip, branch);
}
CommitOutcome::TicketRejected => {
if tx.read_ref(&head_name)? == Some(tip) {
return Ok(());
}
return Err(DispatchError::TicketRejected);
}
CommitOutcome::PacklistNotInRepository => {
if tx.read_ref(&head_name)? == Some(tip) {
return Ok(());
}
return Err(DispatchError::PacklistNotInRepository);
}
CommitOutcome::DeltaBaseUnavailable => return Err(DispatchError::DeltaBaseUnavailable),
_ => return Err(DispatchError::Transport(TransportError::InvalidResponse)),
}
}
Err(DispatchError::PackmapContended {
branch: branch.to_owned(),
})
}
pub(crate) fn commit_head(
tx: &dyn Transport,
head_name: &str,
condition: refs::RefWriteCondition,
tip: &Hash,
branch: &str,
) -> Result<(), DispatchError> {
match tx.update_ref(head_name, condition, tip) {
Ok(()) => Ok(()),
Err(TransportError::RefConflict) => head_conflict(tx, head_name, tip, branch),
Err(e) => Err(super::repository_operation_error(tx, e)),
}
}
fn head_conflict(
tx: &dyn Transport,
head_name: &str,
tip: &Hash,
branch: &str,
) -> Result<(), DispatchError> {
let current = tx
.read_ref(head_name)
.map_err(|error| super::repository_operation_error(tx, error))?;
if current == Some(*tip) {
return Ok(());
}
Err(DispatchError::NonFastForwardPush {
branch: branch.to_owned(),
})
}
pub(crate) struct FetchedChain {
chain: Vec<Hash>,
downloaded: StagedPacks,
}
pub(crate) fn resolve_and_download_chain(
tx: &dyn Transport,
branch: &str,
head_key: Hash,
applied: &AppliedPacks,
) -> Result<FetchedChain, DispatchError> {
let chain = resolve_pack_chain(tx, branch, head_key)?;
let downloaded = download_pack_chain(tx, branch, &chain, applied)?;
Ok(FetchedChain { chain, downloaded })
}
pub(crate) fn apply_fetched_chain(
store: &ObjectStore,
tx: &dyn Transport,
remote: &str,
branch: &str,
fetched: FetchedChain,
tip: Hash,
applied: &mut AppliedPacks,
require_signed: bool,
) -> Result<(), DispatchError> {
let FetchedChain { chain, downloaded } = fetched;
let skipped = chain.len() - downloaded.packs.len();
unpack_downloaded_packs(store, downloaded, applied, require_signed)?;
match super::verify_closure_present(store, &tip) {
Ok(()) => Ok(()),
Err(e @ DispatchError::RemoteMissingObject(_)) if skipped > 0 => {
eprintln!(
"note: applied-packs record for remote '{remote}' branch '{branch}' looks stale ({e}); clearing it and re-fetching the full pack chain"
);
applied.clear();
let downloaded = download_pack_chain(tx, branch, &chain, applied)?;
unpack_downloaded_packs(store, downloaded, applied, require_signed)?;
super::verify_closure_present(store, &tip)
}
Err(e) => Err(e),
}
}
const VERIFY_FANOUT_ENTRIES_PER_THREAD: usize = 2;
fn verify_fanout_threshold() -> usize {
crate::fanout::threshold(VERIFY_FANOUT_ENTRIES_PER_THREAD)
}
const VERIFY_CHUNK_CAP: usize = 512;
fn verify_chunk_size() -> usize {
VERIFY_CHUNK_CAP.max(verify_fanout_threshold())
}
fn verify_new_object_signatures(
store: &ObjectStore,
stored: &[Hash],
require_signed: bool,
) -> Result<(), DispatchError> {
if !require_signed {
return Ok(());
}
if stored.len() < verify_fanout_threshold() {
return verify_slice(store, stored, false);
}
stored
.chunks(verify_chunk_size())
.try_for_each(|chunk| verify_slice(store, chunk, true))
}
fn verify_one_object(h: Hash, obj: &Object) -> Result<(), DispatchError> {
sign::verify_object_signature(obj).map_err(|e| DispatchError::UnsignedOrInvalidObject {
hash: hash::to_hex(&h),
reason: e.to_string(),
})
}
fn collect_batch_entries(
entries: &[(Hash, Object)],
) -> Option<Vec<(sign::PublicKey, Hash, sign::Signature)>> {
let mut batch = Vec::new();
for (_, obj) in entries {
match obj {
Object::Commit(c) => batch.push((
sign::PublicKey(c.signer),
sign::commit_signing_hash(c).ok()?,
sign::Signature(c.signature),
)),
Object::Remix(r) => batch.push((
sign::PublicKey(r.signer),
sign::remix_signing_hash(r).ok()?,
sign::Signature(r.signature),
)),
Object::Tag(t) => batch.push((
sign::PublicKey(t.signer),
sign::tag_signing_hash(t).ok()?,
sign::Signature(t.signature),
)),
Object::Blob(_) | Object::Tree(_) | Object::ChunkedBlob(_) | Object::Delta(_) => {}
}
}
Some(batch)
}
fn verify_batch_parallel(batch: &[(sign::PublicKey, Hash, sign::Signature)]) -> bool {
let threads = rayon::current_num_threads().max(1);
let chunk_size = batch.len().div_ceil(threads).max(1);
batch
.par_chunks(chunk_size)
.all(|sub| sign::verify_batch(sub).is_ok())
}
fn verify_slice(store: &ObjectStore, hashes: &[Hash], parallel: bool) -> Result<(), DispatchError> {
let read_signed_one = |h: &Hash| -> Result<Option<(Hash, Object)>, DispatchError> {
let obj = store.read_object(h)?;
Ok(match obj {
Object::Commit(_) | Object::Remix(_) | Object::Tag(_) => Some((*h, obj)),
Object::Blob(_) | Object::Tree(_) | Object::ChunkedBlob(_) | Object::Delta(_) => None,
})
};
let entries: Vec<(Hash, Object)> = if parallel {
hashes
.par_iter()
.filter_map(|h| read_signed_one(h).transpose())
.collect::<Result<_, _>>()?
} else {
hashes
.iter()
.filter_map(|h| read_signed_one(h).transpose())
.collect::<Result<_, _>>()?
};
if let Some(batch) = collect_batch_entries(&entries) {
let batch_ok = batch.is_empty()
|| if parallel {
verify_batch_parallel(&batch)
} else {
sign::verify_batch(&batch).is_ok()
};
if batch_ok {
return Ok(());
}
}
if parallel {
entries
.par_iter()
.try_for_each(|(h, obj)| verify_one_object(*h, obj))
} else {
entries
.iter()
.try_for_each(|(h, obj)| verify_one_object(*h, obj))
}
}
struct StagedPacks {
directory: tempfile::TempDir,
packs: Vec<StagedPack>,
bytes: u64,
}
struct StagedPack {
key: PackKey,
bytes: usize,
}
fn download_pack_chain(
tx: &dyn Transport,
branch: &str,
chain: &[Hash],
applied: &AppliedPacks,
) -> Result<StagedPacks, DispatchError> {
download_pack_chain_with_limits(tx, branch, chain, applied, MAX_FETCH_STAGED_BYTES, None)
}
fn download_pack_chain_with_limits(
tx: &dyn Transport,
branch: &str,
chain: &[Hash],
applied: &AppliedPacks,
disk_limit: u64,
stage_parent: Option<&std::path::Path>,
) -> Result<StagedPacks, DispatchError> {
if chain.len() > MAX_FETCH_PACKS {
return Err(DispatchError::PackChainInvalid {
branch: branch.to_owned(),
});
}
let directory = match stage_parent {
Some(parent) => tempfile::Builder::new()
.prefix("mkit-fetch-")
.tempdir_in(parent)?,
None => tempfile::Builder::new().prefix("mkit-fetch-").tempdir()?,
};
let mut staged = StagedPacks {
directory,
packs: Vec::new(),
bytes: 0,
};
let ref_name = branch_ref_hint(branch);
for &pk in chain {
if crate::signal::is_shutdown() {
return Err(DispatchError::Interrupted);
}
let key = PackKey::from_hash(pk);
if applied.contains(&key) {
continue;
}
let pack = match tx.download_pack_via_ref(&key, &ref_name) {
Ok(b) => b,
Err(TransportError::PackNotFound) => {
return Err(DispatchError::AdvertisedPackMissing {
branch: branch.to_owned(),
pack: mkit_core::hash::to_hex(&pk),
});
}
Err(e) => return Err(e.into()),
};
#[cfg(test)]
observe_retained_pack_bytes(pack.capacity());
if pack.len() as u64 > mkit_core::protocol::PACK_BODY_LIMIT {
return Err(TransportError::PayloadTooLarge(pack.len()).into());
}
key.verify_bytes(&pack)?;
let next_bytes = staged
.bytes
.checked_add(pack.len() as u64)
.filter(|&bytes| bytes <= disk_limit)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::StorageFull,
format!("fetch staging exceeds the {disk_limit}-byte disk budget"),
)
})?;
let path = staged.directory.path().join(staged.packs.len().to_string());
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(&pack)?;
staged.packs.push(StagedPack {
key,
bytes: pack.len(),
});
staged.bytes = next_bytes;
}
Ok(staged)
}
#[cfg(test)]
std::thread_local! {
static PEAK_RETAINED_PACK_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn observe_retained_pack_bytes(bytes: usize) {
PEAK_RETAINED_PACK_BYTES.with(|peak| peak.set(peak.get().max(bytes)));
}
fn unpack_downloaded_packs(
store: &ObjectStore,
staged: StagedPacks,
applied: &mut AppliedPacks,
require_signed: bool,
) -> Result<(), DispatchError> {
for (index, entry) in staged.packs.iter().enumerate() {
if crate::signal::is_shutdown() {
return Err(DispatchError::Interrupted);
}
let path = staged.directory.path().join(index.to_string());
let file = std::fs::File::open(&path)?;
let mut pack = Vec::new();
file.take(entry.bytes as u64 + 1).read_to_end(&mut pack)?;
if pack.len() != entry.bytes {
return Err(TransportError::InvalidResponse.into());
}
entry.key.verify_bytes(&pack)?;
let report = PackReader::read(&pack, store)?;
let unpacked = (report.raw_count + report.delta_count) as usize;
if unpacked > 0 {
crate::progress::report(crate::progress::Event::ObjectsUnpacked(unpacked));
}
verify_new_object_signatures(store, &report.stored, require_signed)?;
applied.insert(&entry.key);
std::fs::remove_file(path)?;
}
drop(staged);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mkit_core::hash;
use mkit_transport_memory::MemoryTransport;
fn h(seed: &str) -> Hash {
hash::hash(seed.as_bytes())
}
#[test]
fn requested_pack_identity_rejects_valid_substitute_before_unpack() {
use mkit_core::layout::RepoLayout;
use mkit_core::object::Blob;
use mkit_core::pack::PackWriter;
let objects = [
Object::Blob(Blob {
data: b"first".to_vec(),
}),
Object::Blob(Blob {
data: b"second".to_vec(),
}),
];
let mut a = PackWriter::new();
let mut b = PackWriter::new();
for object in &objects {
a.push_raw(
object.id().unwrap(),
&mkit_core::serialize::serialize(object).unwrap(),
)
.unwrap();
}
for object in objects.iter().rev() {
b.push_raw(
object.id().unwrap(),
&mkit_core::serialize::serialize(object).unwrap(),
)
.unwrap();
}
let a = a.finish().unwrap();
let b = b.finish().unwrap();
let key = pack::pack_key(&a);
assert_ne!(key, pack::pack_key(&b));
let tx = MemoryTransport::new();
tx.upload_pack(&b, &PackKey::from_hash(key)).unwrap();
let td = tempfile::tempdir().unwrap();
let applied = AppliedPacks::load(&RepoLayout::single(td.path()), "origin").unwrap();
assert!(matches!(
download_pack_chain(&tx, "main", &[key], &applied),
Err(DispatchError::Transport(TransportError::InvalidResponse))
));
assert!(!applied.contains(&PackKey::from_hash(key)));
}
#[test]
fn requested_packlist_identity_rejects_substitute() {
let tx = MemoryTransport::new();
let a = transfer::encode_packlist(None, &[h("pack-a")]).unwrap();
let b = transfer::encode_packlist(None, &[h("pack-b")]).unwrap();
let key = hash::hash(&a);
tx.upload_blob(&b, &PackKey::from_hash(key)).unwrap();
assert!(matches!(
download_packlist_node(&tx, key, "refs/heads/main"),
Err(DispatchError::Transport(TransportError::InvalidResponse))
));
}
#[test]
fn fetch_retains_at_most_one_downloaded_pack() {
let tx = MemoryTransport::new();
let bytes = vec![0xAB; 1024 * 1024];
let key = hash::hash(&bytes);
tx.upload_pack(&bytes, &PackKey::from_hash(key)).unwrap();
let td = tempfile::tempdir().unwrap();
let applied =
AppliedPacks::load(&mkit_core::layout::RepoLayout::single(td.path()), "origin")
.unwrap();
PEAK_RETAINED_PACK_BYTES.with(|peak| peak.set(0));
let downloaded = download_pack_chain(&tx, "main", &vec![key; 32], &applied).unwrap();
let peak = PEAK_RETAINED_PACK_BYTES.with(std::cell::Cell::get);
assert!(
peak <= bytes.len(),
"retained {peak} bytes for one-MiB packs"
);
drop(downloaded);
}
#[test]
fn fetch_staging_budget_failure_cleans_every_partial_file() {
let tx = MemoryTransport::new();
let bytes = vec![0xAB; 128];
let key = hash::hash(&bytes);
tx.upload_pack(&bytes, &PackKey::from_hash(key)).unwrap();
let td = tempfile::tempdir().unwrap();
let applied =
AppliedPacks::load(&mkit_core::layout::RepoLayout::single(td.path()), "origin")
.unwrap();
let result = download_pack_chain_with_limits(
&tx,
"main",
&[key, key],
&applied,
128,
Some(td.path()),
);
assert!(
matches!(result, Err(DispatchError::Io(ref error)) if error.kind() == std::io::ErrorKind::StorageFull)
);
assert_eq!(std::fs::read_dir(td.path()).unwrap().count(), 0);
assert!(!applied.contains(&PackKey::from_hash(key)));
}
#[test]
fn fetch_staging_drop_removes_successful_downloads() {
let tx = MemoryTransport::new();
let bytes = b"downloaded object bytes";
let key = hash::hash(bytes);
tx.upload_pack(bytes, &PackKey::from_hash(key)).unwrap();
let td = tempfile::tempdir().unwrap();
let applied =
AppliedPacks::load(&mkit_core::layout::RepoLayout::single(td.path()), "origin")
.unwrap();
let staged =
download_pack_chain_with_limits(&tx, "main", &[key], &applied, 1024, Some(td.path()))
.unwrap();
assert_eq!(
std::fs::read(staged.directory.path().join("0")).unwrap(),
bytes
);
assert_eq!(staged.bytes, bytes.len() as u64);
drop(staged);
assert_eq!(std::fs::read_dir(td.path()).unwrap().count(), 0);
}
#[test]
fn fetch_staging_digest_failure_cleans_prior_downloads() {
let tx = MemoryTransport::new();
let valid = b"valid first bytes";
let first = hash::hash(valid);
let wrong = h("missing expected bytes");
tx.upload_pack(valid, &PackKey::from_hash(first)).unwrap();
tx.upload_pack(b"substitute", &PackKey::from_hash(wrong))
.unwrap();
let td = tempfile::tempdir().unwrap();
let applied =
AppliedPacks::load(&mkit_core::layout::RepoLayout::single(td.path()), "origin")
.unwrap();
assert!(matches!(
download_pack_chain_with_limits(
&tx,
"main",
&[first, wrong],
&applied,
1024,
Some(td.path())
),
Err(DispatchError::Transport(TransportError::InvalidResponse))
));
assert_eq!(std::fs::read_dir(td.path()).unwrap().count(), 0);
}
fn put_node(tx: &MemoryTransport, prev: Option<Hash>, packs: &[Hash]) -> Hash {
let bytes = transfer::encode_packlist(prev, packs).unwrap();
let key = hash::hash(&bytes);
tx.upload_blob(&bytes, &PackKey::from_hash(key)).unwrap();
key
}
#[test]
fn probe_chain_depth_counts_nodes_and_matches_resolve_pack_chain() {
let tx = MemoryTransport::new();
let n1 = put_node(&tx, None, &[h("pack1")]);
let n2 = put_node(&tx, Some(n1), &[h("pack2")]);
let n3 = put_node(&tx, Some(n2), &[h("pack3")]);
let probed = probe_chain(&tx, "main", n3).unwrap();
assert_eq!(probed.depth, 3);
let packs = resolve_pack_chain(&tx, "main", n3).unwrap();
assert_eq!(packs, vec![h("pack1"), h("pack2"), h("pack3")]);
assert_eq!(probed.depth, packs.len());
assert_eq!(probed.packs, packs);
assert_eq!(probed.head, n3);
}
#[test]
fn probe_chain_depth_of_a_single_node_chain_is_one() {
let tx = MemoryTransport::new();
let solo = put_node(&tx, None, &[h("pack-solo")]);
assert_eq!(probe_chain(&tx, "main", solo).unwrap().depth, 1);
}
#[test]
fn probe_chain_errors_on_a_cycle_exactly_like_resolve_pack_chain() {
let tx = MemoryTransport::new();
let a = h("cycle-a");
let b = h("cycle-b");
let a_bytes = transfer::encode_packlist(Some(b), &[h("pack-a")]).unwrap();
let b_bytes = transfer::encode_packlist(Some(a), &[h("pack-b")]).unwrap();
tx.upload_blob(&a_bytes, &PackKey::from_hash(a)).unwrap();
tx.upload_blob(&b_bytes, &PackKey::from_hash(b)).unwrap();
assert!(matches!(
probe_chain(&tx, "main", a).unwrap_err(),
DispatchError::PackChainInvalid { .. }
));
assert!(matches!(
resolve_pack_chain(&tx, "main", a).unwrap_err(),
DispatchError::PackChainInvalid { .. }
));
}
#[test]
fn probe_chain_errors_on_an_undownloadable_node_like_resolve_pack_chain() {
let tx = MemoryTransport::new();
let ghost = h("never-uploaded");
assert!(matches!(
probe_chain(&tx, "main", ghost).unwrap_err(),
DispatchError::PackChainInvalid { .. }
));
assert!(matches!(
resolve_pack_chain(&tx, "main", ghost).unwrap_err(),
DispatchError::PackChainInvalid { .. }
));
}
fn decode_node_at(tx: &MemoryTransport, key: Hash) -> transfer::PackListNode {
let bytes = tx.download_blob(&PackKey::from_hash(key)).unwrap();
transfer::decode_packlist(&bytes).unwrap()
}
#[test]
fn advance_packmap_multi_key_first_push_writes_one_node_in_order() {
let tx = MemoryTransport::new();
let (k1, k2, k3) = (h("k1"), h("k2"), h("k3"));
let tip = h("tip");
advance_packmap(
&tx,
"main",
&[k1, k2, k3],
ChainAction::Append {
self_contained: true,
},
None,
refs::RefWriteCondition::Missing,
tip,
)
.unwrap();
let pm_head = tx.read_ref(&packmap_ref("main")).unwrap().unwrap();
let node = decode_node_at(&tx, pm_head);
assert_eq!(node.prev, None, "first push has no prior chain");
assert_eq!(
node.packs,
vec![k1, k2, k3],
"all keys land on ONE node, in build/apply order"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
}
#[test]
fn advance_packmap_single_key_matches_pre_831_shape() {
let tx = MemoryTransport::new();
let k1 = h("only-key");
let tip = h("tip");
advance_packmap(
&tx,
"main",
&[k1],
ChainAction::Append {
self_contained: true,
},
None,
refs::RefWriteCondition::Missing,
tip,
)
.unwrap();
let pm_head = tx.read_ref(&packmap_ref("main")).unwrap().unwrap();
assert_eq!(decode_node_at(&tx, pm_head).packs, vec![k1]);
}
#[test]
fn advance_packmap_is_idempotent_when_every_key_already_chained() {
let tx = MemoryTransport::new();
let (k1, k2, k3) = (h("k1"), h("k2"), h("k3"));
let prior_head = put_node(&tx, None, &[k1, k2, k3]);
tx.update_ref(
&packmap_ref("main"),
refs::RefWriteCondition::Missing,
&prior_head,
)
.unwrap();
let tip = h("tip");
advance_packmap(
&tx,
"main",
&[k1, k2, k3],
ChainAction::Append {
self_contained: true,
},
None,
refs::RefWriteCondition::Missing,
tip,
)
.unwrap();
assert_eq!(
tx.read_ref(&packmap_ref("main")).unwrap(),
Some(prior_head),
"idempotent retry must not write a new node"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
}
#[test]
fn advance_packmap_appends_when_only_some_keys_already_chained() {
let tx = MemoryTransport::new();
let (k1, k2, k3) = (h("k1"), h("k2"), h("k3"));
let prior_head = put_node(&tx, None, &[k1]);
tx.update_ref(
&packmap_ref("main"),
refs::RefWriteCondition::Missing,
&prior_head,
)
.unwrap();
let tip = h("tip");
advance_packmap(
&tx,
"main",
&[k1, k2, k3],
ChainAction::Append {
self_contained: true,
},
None,
refs::RefWriteCondition::Missing,
tip,
)
.unwrap();
let new_head = tx.read_ref(&packmap_ref("main")).unwrap().unwrap();
assert_ne!(new_head, prior_head, "a new node must be appended");
let node = decode_node_at(&tx, new_head);
assert_eq!(node.prev, Some(prior_head));
assert_eq!(node.packs, vec![k1, k2, k3]);
assert_eq!(
resolve_pack_chain(&tx, "main", new_head).unwrap(),
vec![k1, k1, k2, k3],
);
}
#[derive(Clone, Copy)]
enum Reissue {
HeadUpdate,
Advance,
None,
}
struct LostResponseTransport {
inner: MemoryTransport,
reissue: Reissue,
}
impl LostResponseTransport {
fn new(reissue: Reissue) -> Self {
Self {
inner: MemoryTransport::new(),
reissue,
}
}
fn atomic_advance_once(
&self,
head: (&str, refs::RefWriteCondition, &Hash),
packmap: (&str, refs::RefWriteCondition, &Hash),
) -> Result<AdvanceOutcome, TransportError> {
fn holds(c: refs::RefWriteCondition, cur: Option<Hash>) -> bool {
match c {
refs::RefWriteCondition::Any => true,
refs::RefWriteCondition::Missing => cur.is_none(),
refs::RefWriteCondition::Match(h) => cur == Some(h),
}
}
if !holds(packmap.1, self.inner.read_ref(packmap.0)?) {
return Ok(AdvanceOutcome::PackmapConflict);
}
if !holds(head.1, self.inner.read_ref(head.0)?) {
return Ok(AdvanceOutcome::HeadConflict);
}
self.inner.update_ref(packmap.0, packmap.1, packmap.2)?;
self.inner.update_ref(head.0, head.1, head.2)?;
Ok(AdvanceOutcome::Committed)
}
}
impl Transport for LostResponseTransport {
fn upload_pack(&self, bytes: &[u8], key: &PackKey) -> Result<(), TransportError> {
self.inner.upload_pack(bytes, key)
}
fn download_pack(&self, key: &PackKey) -> Result<Vec<u8>, TransportError> {
self.inner.download_pack(key)
}
fn pack_exists(&self, key: &PackKey) -> Result<bool, TransportError> {
self.inner.pack_exists(key)
}
fn upload_blob(&self, bytes: &[u8], key: &PackKey) -> Result<(), TransportError> {
self.inner.upload_blob(bytes, key)
}
fn download_blob(&self, key: &PackKey) -> Result<Vec<u8>, TransportError> {
self.inner.download_blob(key)
}
fn update_ref(
&self,
name: &str,
condition: refs::RefWriteCondition,
hash: &Hash,
) -> Result<(), TransportError> {
if matches!(self.reissue, Reissue::HeadUpdate) && name.starts_with("refs/heads/") {
let _ = self.inner.update_ref(name, condition, hash);
}
self.inner.update_ref(name, condition, hash)
}
fn read_ref(&self, name: &str) -> Result<Option<Hash>, TransportError> {
self.inner.read_ref(name)
}
fn list_refs(&self, prefix: &str) -> Result<Vec<refs::Ref>, TransportError> {
self.inner.list_refs(prefix)
}
fn advance_refs(
&self,
head_ref: &str,
head_condition: refs::RefWriteCondition,
head_value: &Hash,
packmap_ref: &str,
packmap_condition: refs::RefWriteCondition,
packmap_value: &Hash,
) -> Result<AdvanceOutcome, TransportError> {
let head = (head_ref, head_condition, head_value);
let packmap = (packmap_ref, packmap_condition, packmap_value);
match self.reissue {
Reissue::Advance => {
let _ = self.atomic_advance_once(head, packmap)?;
self.atomic_advance_once(head, packmap)
}
Reissue::HeadUpdate | Reissue::None => {
match self.update_ref(packmap_ref, packmap_condition, packmap_value) {
Ok(()) => {}
Err(TransportError::RefConflict) => {
return Ok(AdvanceOutcome::PackmapConflict);
}
Err(e) => return Err(e),
}
match self.update_ref(head_ref, head_condition, head_value) {
Ok(()) => Ok(AdvanceOutcome::Committed),
Err(TransportError::RefConflict) => Ok(AdvanceOutcome::HeadConflict),
Err(e) => Err(e),
}
}
}
}
fn supports_atomic_advance(&self) -> bool {
matches!(self.reissue, Reissue::Advance)
}
}
fn advance_first_push(
tx: &dyn Transport,
keys: &[Hash],
tip: Hash,
) -> Result<(), DispatchError> {
advance_packmap(
tx,
"main",
keys,
ChainAction::Append {
self_contained: true,
},
None,
refs::RefWriteCondition::Missing,
tip,
)
}
#[test]
fn advance_packmap_head_conflict_from_reissued_head_write_is_success() {
let tx = LostResponseTransport::new(Reissue::HeadUpdate);
let tip = h("tip");
advance_first_push(&tx, &[h("k1")], tip).unwrap();
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
let pm = tx.read_ref(&packmap_ref("main")).unwrap().unwrap();
assert_eq!(decode_node_at(&tx.inner, pm).packs, vec![h("k1")]);
}
#[test]
fn advance_packmap_reissued_atomic_advance_is_success() {
let tx = LostResponseTransport::new(Reissue::Advance);
let tip = h("tip");
advance_first_push(&tx, &[h("k1"), h("k2")], tip).unwrap();
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
let pm = tx.read_ref(&packmap_ref("main")).unwrap().unwrap();
let node = decode_node_at(&tx.inner, pm);
assert_eq!(node.prev, None, "no redundant node was appended");
assert_eq!(node.packs, vec![h("k1"), h("k2")]);
}
#[test]
fn advance_packmap_reissued_rebaseline_reset_is_success() {
let tx = LostResponseTransport::new(Reissue::Advance);
let old = h("old-tip");
let prior = put_node(&tx.inner, None, &[h("old-pack")]);
tx.inner
.update_ref(
&packmap_ref("main"),
refs::RefWriteCondition::Missing,
&prior,
)
.unwrap();
tx.inner
.update_ref("refs/heads/main", refs::RefWriteCondition::Missing, &old)
.unwrap();
let tip = h("tip");
advance_packmap(
&tx,
"main",
&[h("full")],
ChainAction::ResetSelfContained,
None,
refs::RefWriteCondition::Match(old),
tip,
)
.unwrap();
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
}
#[test]
fn advance_packmap_genuine_head_conflict_stays_non_fast_forward() {
for reissue in [Reissue::None, Reissue::Advance] {
let tx = LostResponseTransport::new(reissue);
let theirs = h("their-tip");
tx.inner
.update_ref("refs/heads/main", refs::RefWriteCondition::Missing, &theirs)
.unwrap();
let err = advance_first_push(&tx, &[h("k1")], h("tip")).unwrap_err();
assert!(
matches!(err, DispatchError::NonFastForwardPush { ref branch } if branch == "main"),
"{err:?}"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(theirs));
}
}
#[test]
fn commit_head_reissued_write_is_success() {
let tx = LostResponseTransport::new(Reissue::HeadUpdate);
let tip = h("tip");
commit_head(
&tx,
"refs/heads/main",
refs::RefWriteCondition::Missing,
&tip,
"main",
)
.unwrap();
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(tip));
}
#[test]
fn commit_head_genuine_conflict_stays_non_fast_forward() {
let tx = LostResponseTransport::new(Reissue::None);
let theirs = h("their-tip");
tx.inner
.update_ref("refs/heads/main", refs::RefWriteCondition::Missing, &theirs)
.unwrap();
let err = commit_head(
&tx,
"refs/heads/main",
refs::RefWriteCondition::Match(h("stale-lease")),
&h("tip"),
"main",
)
.unwrap_err();
assert!(
matches!(err, DispatchError::NonFastForwardPush { ref branch } if branch == "main"),
"{err:?}"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(theirs));
}
fn signed_commit_bytes(kp: &mkit_core::sign::KeyPair, seed: usize) -> Vec<u8> {
use mkit_core::object::{Commit, Identity};
let mut c = Commit::new_unannotated(
hash::hash(format!("tree #{seed}").as_bytes()),
Vec::new(),
Identity::ed25519(kp.public.0),
kp.public.0,
format!("packmap fanout test fixture #{seed}").into_bytes(),
1_700_000_000 + seed as u64,
[0u8; 64],
);
c.signature = mkit_core::sign::sign_commit(&c, kp).unwrap().0;
mkit_core::serialize::serialize(&Object::Commit(c)).unwrap()
}
const LARGE_BATCH: usize = 512;
#[test]
fn verify_new_object_signatures_accepts_a_large_validly_signed_batch() {
let dir = tempfile::tempdir().unwrap();
let layout = mkit_core::layout::RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let kp = mkit_core::sign::KeyPair::generate().unwrap();
let stored: Vec<Hash> = (0..LARGE_BATCH)
.map(|i| store.write(&signed_commit_bytes(&kp, i)).unwrap())
.collect();
assert!(stored.len() >= verify_fanout_threshold());
verify_new_object_signatures(&store, &stored, true)
.expect("every commit in the batch is validly signed");
}
#[test]
fn verify_new_object_signatures_mixed_with_unsigned_object_kinds() {
let dir = tempfile::tempdir().unwrap();
let layout = mkit_core::layout::RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let kp = mkit_core::sign::KeyPair::generate().unwrap();
let blob = |seed: usize| -> Hash {
store
.write(
&mkit_core::serialize::serialize(&Object::Blob(mkit_core::object::Blob {
data: format!("unsigned blob fixture #{seed}").into_bytes(),
}))
.unwrap(),
)
.unwrap()
};
let mut stored: Vec<Hash> = Vec::with_capacity(2 * LARGE_BATCH);
for i in 0..LARGE_BATCH {
stored.push(blob(i));
stored.push(store.write(&signed_commit_bytes(&kp, i)).unwrap());
}
assert!(stored.len() >= verify_fanout_threshold());
verify_new_object_signatures(&store, &stored, true)
.expect("unsigned entries must not affect verifying the signed ones");
let commit_index = 2 * (LARGE_BATCH / 2) + 1;
let Object::Commit(mut c) = store.read_object(&stored[commit_index]).unwrap() else {
panic!("expected commit");
};
c.signature[0] ^= 0xff;
let tampered_bytes = mkit_core::serialize::serialize(&Object::Commit(c)).unwrap();
stored[commit_index] = store.write(&tampered_bytes).unwrap();
let err = verify_new_object_signatures(&store, &stored, true)
.expect_err("a tampered commit must still be caught alongside unsigned entries");
assert!(matches!(err, DispatchError::UnsignedOrInvalidObject { .. }));
}
#[test]
fn verify_new_object_signatures_rejects_one_bad_signature_in_a_large_batch() {
let dir = tempfile::tempdir().unwrap();
let layout = mkit_core::layout::RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let kp = mkit_core::sign::KeyPair::generate().unwrap();
let mut stored: Vec<Hash> = (0..LARGE_BATCH)
.map(|i| store.write(&signed_commit_bytes(&kp, i)).unwrap())
.collect();
assert!(stored.len() >= verify_fanout_threshold());
let Object::Commit(mut c) = store.read_object(&stored[LARGE_BATCH / 2]).unwrap() else {
panic!("expected commit");
};
c.signature[0] ^= 0xff;
let tampered_bytes = mkit_core::serialize::serialize(&Object::Commit(c)).unwrap();
let tampered_hash = store.write(&tampered_bytes).unwrap();
stored[LARGE_BATCH / 2] = tampered_hash;
let err = verify_new_object_signatures(&store, &stored, true)
.expect_err("a batch with one bad signature must be rejected");
assert!(matches!(err, DispatchError::UnsignedOrInvalidObject { .. }));
}
#[test]
fn verify_new_object_signatures_never_reads_past_the_chunk_containing_the_first_bad_signature()
{
let dir = tempfile::tempdir().unwrap();
let layout = mkit_core::layout::RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let kp = mkit_core::sign::KeyPair::generate().unwrap();
let chunk_size = verify_chunk_size();
let mut stored: Vec<Hash> = (0..chunk_size)
.map(|i| store.write(&signed_commit_bytes(&kp, i)).unwrap())
.collect();
let Object::Commit(mut c) = store.read_object(&stored[0]).unwrap() else {
panic!("expected commit");
};
c.signature[0] ^= 0xff;
let tampered_bytes = mkit_core::serialize::serialize(&Object::Commit(c)).unwrap();
let tampered_hash = store.write(&tampered_bytes).unwrap();
stored[0] = tampered_hash;
stored.extend((0..chunk_size * 3).map(|i| h(&format!("never-written-{i}"))));
let err = verify_new_object_signatures(&store, &stored, true)
.expect_err("the tampered signature in the first chunk must reject the fetch");
match err {
DispatchError::UnsignedOrInvalidObject { hash: got, .. } => {
assert_eq!(got, hash::to_hex(&tampered_hash));
}
other => panic!("expected UnsignedOrInvalidObject, got {other:?}"),
}
}
#[test]
fn verify_new_object_signatures_require_signed_false_skips_a_large_batch() {
let dir = tempfile::tempdir().unwrap();
let layout = mkit_core::layout::RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
let stored: Vec<Hash> = (0..LARGE_BATCH)
.map(|i| h(&format!("never-written-{i}")))
.collect();
verify_new_object_signatures(&store, &stored, false)
.expect("require_signed=false must no-op regardless of batch size");
}
}