mkit_core/transfer.rs
1//! Transfer-layer helpers: delta-aware pack planning and the packlist
2//! discovery wire format.
3//!
4//! These sit ABOVE the on-disk pack format (SPEC-PACKFILE / SPEC-DELTA)
5//! and below the transport. The push path uses [`plan_pack`] to decide
6//! which objects of a ref's closure to send, and how — raw, or as a delta
7//! against a base the remote already holds. The plan is then serialised
8//! with [`crate::pack::PackWriter`] into one or more packs — split when
9//! the plan's payload exceeds a single pack's cap (issue #831; the CLI's
10//! `remote_dispatch::build_and_upload_packs` owns the splitting) — each
11//! keyed by its own BLAKE3 digest.
12//!
13//! Because a delta-encoded pack is keyed by the pack digest (not by the
14//! reconstructed object's hash), the fetch side can no longer find it by
15//! walking object hashes. Each push records a [`PackListNode`] — this
16//! module's small versioned wire format — holding the pack(s) it added plus
17//! a `prev` pointer to the previous node, forming a per-branch chain. The
18//! push side advertises the chain head through a `refs/mkit/packmap/<branch>`
19//! metadata ref whose value is the BLAKE3 of the head node (itself stored as
20//! a pack object). The fetch side reads that ref, walks the chain, and
21//! unpacks every pack oldest-first. Chaining keeps each push O(1) on the
22//! wire rather than re-uploading the whole history's list.
23//!
24//! Content addressing is unchanged: delta is a transfer encoding only. The
25//! reconstructed object's id (BLAKE3 of its canonical bytes, or the merkle
26//! BMT root for a `Tree`/`ChunkedBlob` — see `crate::merkle`) is
27//! re-verified by `PackReader` before storing.
28
29use std::collections::{BTreeSet, HashMap, HashSet};
30
31use crate::delta;
32use crate::hash::{self, Hash};
33use crate::object::{Object, ObjectType};
34use crate::store::{ObjectStore, StoreError};
35
36// ---------------------------------------------------------------------------
37// PackList wire format
38// ---------------------------------------------------------------------------
39
40/// ASCII magic at the start of every packlist node ("mkit pack list").
41pub const PACKLIST_MAGIC: &[u8; 4] = b"MKPL";
42/// Current packlist version. Readers reject anything else so a future
43/// format change is a loud error, not a silent misparse.
44pub const PACKLIST_VERSION: u8 = 1;
45/// Hard cap on packs recorded in a single node — a normal push records
46/// one; refuse to allocate unboundedly on a malformed blob.
47pub const PACKLIST_MAX_ENTRIES: u32 = 1_000_000;
48
49/// The fixed guard header preceding the codec body: `[4B magic][1B
50/// version]`. The body (`prev` pointer + pack list) is encoded with
51/// `commonware-codec` and is variable-length.
52const PACKLIST_HEADER_LEN: usize = 4 + 1;
53
54/// A single node in a branch's packlist chain.
55///
56/// The push path appends one node per push: `prev` points at the previous
57/// node (the current packmap value before this push), and `packs` holds the
58/// pack(s) this push added. The full ordered pack set for a branch is the
59/// chain walked oldest-first — see `remote_dispatch::fetch_pack_chain`.
60///
61/// Chaining keeps each push O(1) on the wire (read a 32-byte pointer, write
62/// a ~64-byte node) instead of re-uploading the whole history's list.
63///
64/// A node's `prev` is reset to `None` (rather than linked to the prior head)
65/// when a push re-baselines (#406) — proactively bounding chain depth, or
66/// reactively escaping a broken chain. Either way the packs the superseded
67/// chain referenced become unreachable from the new head node: they are not
68/// deleted here (this module only ever writes new nodes/packs, never
69/// deletes), so they linger as orphaned storage on the remote until a
70/// server-side sweep reclaims them (tracked as makechain#849). A stale
71/// pack lingering harmlessly is safe; nothing on the fetch side ever
72/// resolves it once no live chain points to it.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PackListNode {
75 /// Previous node's key, or `None` for the first node of a branch.
76 pub prev: Option<Hash>,
77 /// Pack keys added by this node, in apply order.
78 pub packs: Vec<Hash>,
79}
80
81/// Errors decoding a [`PackListNode`] blob.
82#[derive(Debug, thiserror::Error, PartialEq, Eq)]
83pub enum PackListError {
84 #[error("packlist is shorter than the {PACKLIST_HEADER_LEN}-byte magic+version header")]
85 TooShort,
86 #[error("packlist magic is not \"MKPL\"")]
87 InvalidMagic,
88 #[error("packlist version {0} is not supported (v1 only)")]
89 UnsupportedVersion(u8),
90 #[error("packlist pack count exceeds the {PACKLIST_MAX_ENTRIES} cap")]
91 TooManyEntries(u32),
92 /// The codec body (prev pointer / pack list) is malformed, truncated,
93 /// or has trailing bytes.
94 #[error("packlist body is malformed (bad codec payload or trailing bytes)")]
95 Malformed,
96}
97
98/// Serialise one packlist node: the `MKPL`/version guard header followed by
99/// the `prev`/`packs` body encoded with `commonware-codec` (idiomatic
100/// `Option` + `Vec`).
101///
102/// # Errors
103///
104/// [`PackListError::TooManyEntries`] if `packs` exceeds the cap.
105pub fn encode_packlist(prev: Option<Hash>, packs: &[Hash]) -> Result<Vec<u8>, PackListError> {
106 use commonware_codec::Write;
107 let count = u32::try_from(packs.len()).map_err(|_| PackListError::TooManyEntries(u32::MAX))?;
108 if count > PACKLIST_MAX_ENTRIES {
109 return Err(PackListError::TooManyEntries(count));
110 }
111 let mut out = Vec::new();
112 out.extend_from_slice(PACKLIST_MAGIC);
113 out.push(PACKLIST_VERSION);
114 // Body via commonware-codec: an `Option<Hash>` then a `Vec<Hash>`
115 // (length-prefixed). `Vec<u8>` is a `bytes::BufMut`.
116 prev.write(&mut out);
117 packs.to_vec().write(&mut out);
118 Ok(out)
119}
120
121/// Parse a packlist node blob.
122///
123/// # Errors
124///
125/// Returns the matching [`PackListError`] for a short buffer, wrong magic,
126/// or unknown version (the explicit guard header), or
127/// [`PackListError::Malformed`] / [`PackListError::TooManyEntries`] from
128/// the `commonware-codec` body (over-cap pack list, truncation, or
129/// trailing bytes).
130pub fn decode_packlist(bytes: &[u8]) -> Result<PackListNode, PackListError> {
131 use bytes::Buf as _;
132 use commonware_codec::{ReadExt, ReadRangeExt};
133
134 if bytes.len() < PACKLIST_HEADER_LEN {
135 return Err(PackListError::TooShort);
136 }
137 if &bytes[..4] != PACKLIST_MAGIC.as_slice() {
138 return Err(PackListError::InvalidMagic);
139 }
140 let version = bytes[4];
141 if version != PACKLIST_VERSION {
142 return Err(PackListError::UnsupportedVersion(version));
143 }
144 // Body: `Option<Hash>` then a length-capped `Vec<Hash>`. `&[u8]` is a
145 // `bytes::Buf`; the `RangeCfg` enforces the entry cap at decode time.
146 let mut buf: &[u8] = &bytes[PACKLIST_HEADER_LEN..];
147 let prev = <Option<Hash>>::read(&mut buf).map_err(|_| PackListError::Malformed)?;
148 let packs = <Vec<Hash>>::read_range(&mut buf, 0..=PACKLIST_MAX_ENTRIES as usize)
149 .map_err(|_| PackListError::Malformed)?;
150 // Trailing bytes after the declared body are a malformed packlist.
151 if buf.has_remaining() {
152 return Err(PackListError::Malformed);
153 }
154 Ok(PackListNode { prev, packs })
155}
156
157// ---------------------------------------------------------------------------
158// Delta base selection
159// ---------------------------------------------------------------------------
160
161/// Cap on tree-diff recursion depth when pairing chunked blobs across two
162/// commits. Bounds the walk on adversarial / pathologically nested trees.
163const MAX_TREE_DEPTH: usize = 64;
164
165/// Choose, for each changed `FastCDC` chunk or small blob in `new_tip`, a
166/// base object from `old_tip` to delta against.
167///
168/// (SPEC-DELTA §5 is informative — any base the size-gate later accepts is
169/// fine.) Diff the two commits' trees by path; where the same path is a
170/// [`crate::object::ChunkedBlob`] on both sides with a different manifest
171/// hash, pair each changed new chunk against a base in the old manifest —
172/// by same-index when the chunk counts match (an in-place edit didn't shift
173/// boundaries) or by content similarity when they differ (an insert/delete
174/// did, via `pair_chunks`). Identical chunks (present byte-for-byte in the
175/// old manifest) are skipped — they dedup for free and never need a delta.
176/// Where the same path is a plain [`crate::object::Blob`] on both sides
177/// (a changed small file, under `CHUNK_THRESHOLD`), the old blob is paired
178/// as the new blob's delta base directly — no similarity search, just
179/// "same path, old version" (#646a). A path whose kind changed between the
180/// two blob/chunked-blob variants, or that has no old-side counterpart at
181/// all (added path, or a rename), is left unpaired.
182///
183/// The returned map is `new_hash -> base_hash`. Every base is reachable
184/// from `old_tip`, so a remote that already holds `old_tip` holds the
185/// base. The caller still gates on whether the delta actually saves bytes
186/// ([`plan_pack`]).
187///
188/// # Errors
189///
190/// Propagates [`StoreError`] other than `ObjectNotFound`, which is treated
191/// as "no pairing available" (a partial local history is not fatal here —
192/// the worst case is that we send the chunk raw).
193pub fn select_chunk_delta_bases(
194 store: &ObjectStore,
195 new_tip: Hash,
196 old_tip: Hash,
197) -> Result<HashMap<Hash, Hash>, StoreError> {
198 let mut out = HashMap::new();
199 let (Some(new_tree), Some(old_tree)) = (tip_tree(store, new_tip)?, tip_tree(store, old_tip)?)
200 else {
201 return Ok(out);
202 };
203 pair_trees(store, new_tree, old_tree, 0, &mut out)?;
204 Ok(out)
205}
206
207/// Resolve a commit/remix tip to its root tree hash; `None` for any other
208/// object kind (a tip that is not a commit has no tree to diff).
209fn tip_tree(store: &ObjectStore, tip: Hash) -> Result<Option<Hash>, StoreError> {
210 match store.read_object(&tip) {
211 Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
212 Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
213 // A non-commit tip, or one we don't have, simply has no tree to diff.
214 Ok(_) | Err(StoreError::ObjectNotFound(_)) => Ok(None),
215 Err(e) => Err(e),
216 }
217}
218
219/// Merge-join two sorted trees by entry name, recursing into matching
220/// subtrees and pairing chunks for matching chunked-blob entries.
221fn pair_trees(
222 store: &ObjectStore,
223 new_tree: Hash,
224 old_tree: Hash,
225 depth: usize,
226 out: &mut HashMap<Hash, Hash>,
227) -> Result<(), StoreError> {
228 if depth > MAX_TREE_DEPTH || new_tree == old_tree {
229 return Ok(());
230 }
231 let (Some(Object::Tree(new_t)), Some(Object::Tree(old_t))) = (
232 read_optional(store, new_tree)?,
233 read_optional(store, old_tree)?,
234 ) else {
235 return Ok(());
236 };
237
238 // Both trees are lex-sorted by name (SPEC-OBJECTS §4); two-pointer
239 // merge to find entries present under the same name on both sides.
240 let (mut i, mut j) = (0usize, 0usize);
241 while i < new_t.entries.len() && j < old_t.entries.len() {
242 let ne = &new_t.entries[i];
243 let oe = &old_t.entries[j];
244 match ne.name.cmp(&oe.name) {
245 std::cmp::Ordering::Less => i += 1,
246 std::cmp::Ordering::Greater => j += 1,
247 std::cmp::Ordering::Equal => {
248 if ne.object_hash != oe.object_hash {
249 pair_entry(store, ne.object_hash, oe.object_hash, depth, out)?;
250 }
251 i += 1;
252 j += 1;
253 }
254 }
255 }
256 Ok(())
257}
258
259/// Dispatch a changed same-named entry: recurse into subtrees, pair
260/// chunked blobs, pair small blobs against their same-path prior version
261/// (#646a), ignore everything else (in particular, a `Blob<->ChunkedBlob`
262/// kind mismatch — a file crossing `CHUNK_THRESHOLD` between the two
263/// commits — is deliberately left unpaired here).
264fn pair_entry(
265 store: &ObjectStore,
266 new_hash: Hash,
267 old_hash: Hash,
268 depth: usize,
269 out: &mut HashMap<Hash, Hash>,
270) -> Result<(), StoreError> {
271 match (store.read_object(&new_hash), store.read_object(&old_hash)) {
272 (Ok(Object::Tree(_)), Ok(Object::Tree(_))) => {
273 pair_trees(store, new_hash, old_hash, depth + 1, out)
274 }
275 (Ok(Object::ChunkedBlob(new_cb)), Ok(Object::ChunkedBlob(old_cb))) => {
276 pair_chunks(store, &new_cb.chunks, &old_cb.chunks, out)
277 }
278 // A changed same-path small blob: the old blob at this path is a
279 // natural delta base for the new one (#646a). Never overwrite an
280 // existing pairing for `new_hash`, mirroring `pair_chunks`'
281 // determinism guarantee.
282 (Ok(Object::Blob(_)), Ok(Object::Blob(_))) => {
283 out.entry(new_hash).or_insert(old_hash);
284 Ok(())
285 }
286 // A missing object or a kind mismatch (file became a chunked blob,
287 // etc.) just yields no pairing for this entry.
288 (Err(StoreError::ObjectNotFound(_)), _) | (_, Err(StoreError::ObjectNotFound(_))) => Ok(()),
289 (Err(e), _) | (_, Err(e)) => Err(e),
290 _ => Ok(()),
291 }
292}
293
294/// Window size for content-similarity sketching. Matches the delta
295/// encoder's match-block size, so a shared 16-byte run is a shared feature.
296const FEATURE_WINDOW: usize = 16;
297/// Number of min-hash "super-features" sampled per chunk. Small keeps the
298/// index cheap; the delta size-gate in [`plan_pack`] is the final judge of a
299/// pick, so a few features are enough signal to rank candidates.
300const FEATURE_COUNT: usize = 4;
301
302/// Pair each changed new chunk against a base chunk in the old manifest.
303/// Skips chunks already present byte-identically in the old manifest (those
304/// dedup for free) and never overwrites an existing pairing, so the result
305/// is deterministic.
306///
307/// Two regimes:
308///
309/// * **Equal chunk counts** ⇒ `FastCDC` boundaries didn't shift (an in-place,
310/// same-length edit), so same-index pairing lands the new chunk on its own
311/// prior version. This is the common case and reads no chunk content.
312/// * **Differing counts** ⇒ an insert/delete shifted indices, so position is
313/// unreliable. We match by **content similarity**: index every old chunk's
314/// min-hash super-features, then pick, for each changed new chunk, the old
315/// chunk sharing the most features (deterministic tiebreak: lowest hash),
316/// falling back to the clamped same-index chunk when there is no overlap.
317/// This reads the changed file's chunk content (`O(file size)`), but only
318/// when there actually was a shift — and it trades those local reads for a
319/// much smaller upload than re-sending the shifted chunks raw.
320fn pair_chunks(
321 store: &ObjectStore,
322 new_chunks: &[Hash],
323 old_chunks: &[Hash],
324 out: &mut HashMap<Hash, Hash>,
325) -> Result<(), StoreError> {
326 if old_chunks.is_empty() {
327 return Ok(());
328 }
329 let old_set: HashSet<&Hash> = old_chunks.iter().collect();
330
331 // Fast path: no boundary shift → same-index pairing, no content reads.
332 if new_chunks.len() == old_chunks.len() {
333 for (j, nj) in new_chunks.iter().enumerate() {
334 if old_set.contains(nj) || out.contains_key(nj) {
335 continue;
336 }
337 if old_chunks[j] != *nj {
338 out.insert(*nj, old_chunks[j]);
339 }
340 }
341 return Ok(());
342 }
343
344 // Shifted: index old chunks by their super-features once. An unreadable
345 // old chunk (absent / non-blob) contributes no features and can't be a
346 // base — it is simply absent from the index.
347 let mut feature_index: HashMap<u64, Vec<Hash>> = HashMap::new();
348 for oc in old_chunks {
349 if let Some(content) = chunk_bytes(store, oc)? {
350 for f in chunk_features(&content) {
351 feature_index.entry(f).or_default().push(*oc);
352 }
353 }
354 }
355
356 for nj in new_chunks {
357 if old_set.contains(nj) || out.contains_key(nj) {
358 continue;
359 }
360 // An unreadable new chunk has no content signal — skip pairing it
361 // (it's sent raw) rather than guessing a base.
362 let Some(content) = chunk_bytes(store, nj)? else {
363 continue;
364 };
365 // Count shared features per candidate old chunk.
366 let mut votes: HashMap<Hash, u32> = HashMap::new();
367 for f in &chunk_features(&content) {
368 if let Some(cands) = feature_index.get(f) {
369 for cand in cands {
370 *votes.entry(*cand).or_default() += 1;
371 }
372 }
373 }
374 // Pair ONLY on genuine content overlap: most shared features wins,
375 // ties → lowest hash (deterministic, order-independent). No overlap →
376 // no base; the chunk is sent raw (a dissimilar base would be rejected
377 // by the size-gate anyway). No position fallback after a shift.
378 if let Some((base, _)) = votes
379 .into_iter()
380 .filter(|(cand, _)| cand != nj)
381 .max_by(|x, y| x.1.cmp(&y.1).then_with(|| y.0.cmp(&x.0)))
382 {
383 out.insert(*nj, base);
384 }
385 }
386 Ok(())
387}
388
389/// Read a chunk blob's CONTENT bytes (not its serialized object form).
390///
391/// Returns `None` when the object is absent or not a blob — there is no
392/// content to compare, so the caller skips pairing that chunk rather than
393/// inventing a base. A genuine read/IO error propagates, preserving the
394/// [`select_chunk_delta_bases`] contract (only `ObjectNotFound` is swallowed).
395fn chunk_bytes(store: &ObjectStore, h: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
396 match store.read_object(h) {
397 Ok(Object::Blob(b)) => Ok(Some(b.data)),
398 Ok(_) | Err(StoreError::ObjectNotFound(_)) => Ok(None),
399 Err(e) => Err(e),
400 }
401}
402
403/// Up to [`FEATURE_COUNT`] shift-resistant "super-features" for a chunk: the
404/// smallest distinct `FEATURE_WINDOW`-byte FNV-1a window hashes. Two chunks
405/// that share content share these min-hashes with high probability even when
406/// the content is shifted, making them a cheap similarity key. Chunks shorter
407/// than the window yield none (such a chunk has no similarity key, so it is
408/// left unpaired and sent raw).
409fn chunk_features(bytes: &[u8]) -> Vec<u64> {
410 if bytes.len() < FEATURE_WINDOW {
411 return Vec::new();
412 }
413 // Keep the K smallest DISTINCT window hashes, ascending.
414 let mut best: Vec<u64> = Vec::with_capacity(FEATURE_COUNT + 1);
415 for w in bytes.windows(FEATURE_WINDOW) {
416 let h = fnv1a(w);
417 if best.len() == FEATURE_COUNT && h >= best[FEATURE_COUNT - 1] {
418 continue;
419 }
420 if let Err(pos) = best.binary_search(&h) {
421 best.insert(pos, h);
422 best.truncate(FEATURE_COUNT);
423 }
424 }
425 best
426}
427
428/// FNV-1a 64-bit over a fixed window — same primitive the delta writer uses
429/// for block matching, so a shared block surfaces as a shared feature.
430fn fnv1a(block: &[u8]) -> u64 {
431 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
432 for &b in block {
433 h ^= u64::from(b);
434 h = h.wrapping_mul(0x0000_0001_0000_01b3);
435 }
436 h
437}
438
439/// Read an object, mapping a missing object to `None` so callers can treat
440/// "absent" the same as "not the kind I wanted" without a hard error.
441fn read_optional(store: &ObjectStore, h: Hash) -> Result<Option<Object>, StoreError> {
442 match store.read_object(&h) {
443 Ok(o) => Ok(Some(o)),
444 Err(StoreError::ObjectNotFound(_)) => Ok(None),
445 Err(e) => Err(e),
446 }
447}
448
449// ---------------------------------------------------------------------------
450// Pack planning
451// ---------------------------------------------------------------------------
452
453/// One delta entry in a [`PackPlan`]: a target object encoded against a
454/// base the remote already holds.
455#[derive(Debug, Clone)]
456pub struct PlannedDelta {
457 /// BLAKE3 of the reconstructed (canonical) object — its storage id.
458 pub target: Hash,
459 /// BLAKE3 of the base object the delta is applied against.
460 pub base: Hash,
461 /// SPEC-DELTA instruction stream.
462 pub stream: Vec<u8>,
463}
464
465/// A deterministic plan for the pack(s) a push uploads for one ref —
466/// serialised into a single pack when it fits under the payload cap, or
467/// split across several when it doesn't (issue #831).
468///
469/// Entries are pre-ordered for [`crate::pack::PackWriter`]: all `raw`
470/// objects first (non-blobs before blobs, each group in BLAKE3 order),
471/// then `deltas`. Delta bases are external — they live in `old_tip`'s
472/// closure, which the remote already holds and earlier packs already
473/// delivered, NEVER in an entry introduced earlier in this same plan —
474/// so no in-pack base ordering is required (SPEC-PACKFILE §4), and a
475/// left-to-right split of this sequence across multiple packs is always
476/// safe.
477#[derive(Debug, Clone, Default)]
478pub struct PackPlan {
479 /// Objects to send verbatim, already ordered non-blobs-then-blobs.
480 pub raw: Vec<Hash>,
481 /// Objects to send as a delta against an already-present base.
482 pub deltas: Vec<PlannedDelta>,
483 /// `true` when the pack needs no externally-resolved base — i.e. this is
484 /// a full-closure push (no usable `old_tip`), so the pack reconstructs
485 /// the ref's whole closure on its own. The push path normally **appends**
486 /// this pack to the branch's packlist chain; it uses `self_contained`
487 /// to decide whether a push may **reset** to a fresh chain, in two
488 /// cases:
489 ///
490 /// * Reactively, when the prior chain is unreadable — a self-contained
491 /// pack is the one kind that can safely escape a broken chain.
492 /// * Proactively (#406): when a *healthy* chain has simply grown past
493 /// the re-baseline depth threshold, the push side calls [`plan_pack`]
494 /// with `old_tip: None` specifically to force `self_contained: true`,
495 /// then resets the chain to bound its depth. This reuses the atomic
496 /// head+packmap advance (#408) the reactive reset already depends on
497 /// — no separate mechanism was needed once that landed.
498 pub self_contained: bool,
499}
500
501impl PackPlan {
502 /// Total objects the plan transfers (raw + delta).
503 #[must_use]
504 pub fn object_count(&self) -> usize {
505 self.raw.len() + self.deltas.len()
506 }
507
508 /// `true` when the plan carries nothing — the remote already holds the
509 /// ref's whole closure.
510 #[must_use]
511 pub fn is_empty(&self) -> bool {
512 self.raw.is_empty() && self.deltas.is_empty()
513 }
514}
515
516/// Plan the pack for pushing `new_tip` to a remote whose current tip is
517/// `old_tip` (`None` for a first push or a remote we cannot diff against).
518///
519/// Computes the send-set as `closure(new_tip) \ closure(old_tip)` — so
520/// objects the remote already holds are never re-sent (this is the
521/// identical-object dedup, preferred over delta) — then delta-encodes
522/// changed chunks against same-path prior chunks where that actually
523/// saves bytes, falling back to raw otherwise.
524///
525/// # Errors
526///
527/// Propagates [`StoreError`] from reading the local closure. A missing
528/// `old_tip` closure is treated as "diff unavailable" (full-closure,
529/// all-raw push) rather than an error.
530pub fn plan_pack(
531 store: &ObjectStore,
532 new_tip: Hash,
533 old_tip: Option<Hash>,
534) -> Result<PackPlan, StoreError> {
535 let new_set = crate::ops::reachable_objects(store, &new_tip)?;
536
537 // What the remote already holds = the closure of its current tip, if we
538 // can compute it locally. A partial local history (some referenced
539 // object pruned) degrades to "send everything" rather than failing.
540 let mut remote_set = BTreeSet::new();
541 let mut base_map = HashMap::new();
542 let mut have_old = false;
543 if let Some(o) = old_tip
544 && store.contains(&o)
545 {
546 match crate::ops::reachable_objects(store, &o) {
547 Ok(s) => {
548 remote_set = s;
549 base_map = select_chunk_delta_bases(store, new_tip, o)?;
550 have_old = true;
551 }
552 Err(StoreError::ObjectNotFound(_)) => {}
553 Err(e) => return Err(e),
554 }
555 }
556
557 let send: BTreeSet<Hash> = new_set.difference(&remote_set).copied().collect();
558
559 // Partition the send-set, iterating in BTreeSet (BLAKE3) order so the
560 // plan — and therefore the pack bytes — is deterministic.
561 let mut non_blob_raw = Vec::new();
562 let mut blob_raw = Vec::new();
563 let mut deltas = Vec::new();
564
565 for h in &send {
566 // Classify via the cheap 6-byte prologue check (`object_type`)
567 // instead of a full read+verify of the object's bytes — the send-set
568 // classification only needs the type tag, not the content (INV-14).
569 // Bytes are read (and BLAKE3-verified) below, lazily, only for the
570 // subset that are actually delta candidates.
571 let is_blob = store.object_type(h)? == ObjectType::Blob;
572
573 // Only blobs (FastCDC chunks) are delta candidates, and only against
574 // a base the remote actually holds.
575 if is_blob
576 && let Some(base) = base_map.get(h)
577 && remote_set.contains(base)
578 {
579 let bytes = store.read(h)?;
580 if let Some(planned) = try_delta(store, *h, *base, &bytes)? {
581 deltas.push(planned);
582 continue;
583 }
584 }
585
586 if is_blob {
587 blob_raw.push(*h);
588 } else {
589 non_blob_raw.push(*h);
590 }
591 }
592
593 let mut raw = non_blob_raw;
594 raw.extend(blob_raw);
595
596 Ok(PackPlan {
597 raw,
598 deltas,
599 self_contained: !have_old,
600 })
601}
602
603/// Encode `target` against `base` and return the delta only if it is
604/// strictly smaller on the wire than sending the target raw. The per-entry
605/// frame (SPEC-PACKFILE §2) is identical for raw and delta, so only the
606/// payloads differ: a delta payload is `base_hash (HASH_LEN) + stream`
607/// (SPEC-PACKFILE §3.2) versus the raw object bytes. Compare those.
608fn try_delta(
609 store: &ObjectStore,
610 target: Hash,
611 base: Hash,
612 target_bytes: &[u8],
613) -> Result<Option<PlannedDelta>, StoreError> {
614 let base_bytes = store.read(&base)?;
615 let Ok(stream) = delta::encode(&base_bytes, target_bytes) else {
616 // Over-u32 inputs can't happen here (object cap < 4 GiB), but treat
617 // any encode failure as "send raw" rather than propagating.
618 return Ok(None);
619 };
620 if hash::HASH_LEN + stream.len() < target_bytes.len() {
621 Ok(Some(PlannedDelta {
622 target,
623 base,
624 stream,
625 }))
626 } else {
627 Ok(None)
628 }
629}
630
631// =========================================================================
632// Tests
633// =========================================================================
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::chunker::{ChunkIterator, FastCdc};
639 use crate::object::{Blob, ChunkedBlob, Commit, EntryMode, Identity, Tree, TreeEntry};
640 use crate::pack::{PackReader, PackWriter};
641 use crate::serialize;
642 use tempfile::TempDir;
643
644 fn store() -> (TempDir, ObjectStore) {
645 let d = TempDir::new().unwrap();
646 let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
647 (d, s)
648 }
649
650 fn put(s: &ObjectStore, obj: &Object) -> Hash {
651 s.write(&serialize::serialize(obj).unwrap()).unwrap()
652 }
653
654 /// Store `data` as `FastCDC` chunks + a `ChunkedBlob` manifest, mirroring
655 /// the worktree large-file path. Returns the manifest hash.
656 fn put_chunked(s: &ObjectStore, data: &[u8]) -> Hash {
657 let chunks: Vec<Hash> = ChunkIterator::new(FastCdc::v1(), data)
658 .map(|b| {
659 put(
660 s,
661 &Object::Blob(Blob {
662 data: data[b.offset..b.offset + b.length].to_vec(),
663 }),
664 )
665 })
666 .collect();
667 put(
668 s,
669 &Object::ChunkedBlob(ChunkedBlob {
670 total_size: data.len() as u64,
671 chunk_size: 0,
672 chunks,
673 }),
674 )
675 }
676
677 fn commit_with_file(s: &ObjectStore, file_hash: Hash, parents: Vec<Hash>, msg: &str) -> Hash {
678 commit_with_named_file(s, b"big.bin", file_hash, parents, msg)
679 }
680
681 /// Like [`commit_with_file`], but with a caller-chosen tree entry name —
682 /// needed to build trees with more than one entry, or to control whether
683 /// two commits share a path.
684 fn commit_with_named_file(
685 s: &ObjectStore,
686 name: &[u8],
687 file_hash: Hash,
688 parents: Vec<Hash>,
689 msg: &str,
690 ) -> Hash {
691 let tree = put(
692 s,
693 &Object::Tree(Tree {
694 entries: vec![TreeEntry {
695 name: name.to_vec(),
696 mode: EntryMode::Blob,
697 object_hash: file_hash,
698 }],
699 }),
700 );
701 put(
702 s,
703 &Object::Commit(Commit::new_unannotated(
704 tree,
705 parents,
706 Identity::ed25519([7; 32]),
707 [0; 32],
708 msg.as_bytes().to_vec(),
709 msg.len() as u64,
710 [0; 64],
711 )),
712 )
713 }
714
715 /// A >1 MiB pseudo-random buffer (deterministic) that `FastCDC` splits
716 /// into several chunks. Splitmix64 keeps it dependency-free and
717 /// reproducible.
718 fn big_buffer() -> Vec<u8> {
719 let mut data = vec![0u8; 2 * 1024 * 1024];
720 let mut state: u64 = 0x1234_5678_9abc_def0;
721 for chunk in data.chunks_mut(8) {
722 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
723 let mut z = state;
724 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
725 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
726 z ^= z >> 31;
727 let bytes = z.to_le_bytes();
728 let n = chunk.len();
729 chunk.copy_from_slice(&bytes[..n]);
730 }
731 data
732 }
733
734 #[test]
735 fn packlist_node_roundtrip_with_prev() {
736 let prev = [0x11u8; 32];
737 let packs = vec![[1u8; 32], [2u8; 32], [0xABu8; 32]];
738 let bytes = encode_packlist(Some(prev), &packs).unwrap();
739 assert_eq!(&bytes[..4], PACKLIST_MAGIC);
740 assert_eq!(bytes[4], PACKLIST_VERSION);
741 let node = decode_packlist(&bytes).unwrap();
742 assert_eq!(node.prev, Some(prev));
743 assert_eq!(node.packs, packs);
744 }
745
746 #[test]
747 fn packlist_node_roundtrip_first_node() {
748 // First node of a branch: no predecessor, one pack.
749 let bytes = encode_packlist(None, &[[7u8; 32]]).unwrap();
750 let node = decode_packlist(&bytes).unwrap();
751 assert_eq!(node.prev, None);
752 assert_eq!(node.packs, vec![[7u8; 32]]);
753 }
754
755 #[test]
756 fn packlist_rejects_bad_magic_version_body_and_length() {
757 let good = encode_packlist(Some([0x11u8; 32]), &[[9u8; 32]]).unwrap();
758
759 // Magic and version are the explicit guard header (precise errors).
760 let mut bad_magic = good.clone();
761 bad_magic[0] = b'X';
762 assert_eq!(
763 decode_packlist(&bad_magic),
764 Err(PackListError::InvalidMagic)
765 );
766
767 let mut bad_ver = good.clone();
768 bad_ver[4] = 2;
769 assert_eq!(
770 decode_packlist(&bad_ver),
771 Err(PackListError::UnsupportedVersion(2))
772 );
773
774 // Truncated codec body → Malformed.
775 let mut short = good.clone();
776 short.pop();
777 assert_eq!(decode_packlist(&short), Err(PackListError::Malformed));
778
779 // Trailing byte after the declared body → Malformed.
780 let mut long = good;
781 long.push(0);
782 assert_eq!(decode_packlist(&long), Err(PackListError::Malformed));
783
784 // Shorter than the magic+version guard header → TooShort.
785 assert_eq!(decode_packlist(&[0u8; 3]), Err(PackListError::TooShort));
786 }
787
788 #[test]
789 fn packlist_decode_rejects_over_cap_pack_list() {
790 // A REAL over-cap body: `encode_packlist` refuses to build one,
791 // so assemble the node by hand with the same codec — the guard
792 // header followed by `Option<Hash>` + a `Vec<Hash>` holding
793 // PACKLIST_MAX_ENTRIES + 1 entries (~32 MiB). The decode-side
794 // RangeCfg must reject it as Malformed, not allocate it.
795 use commonware_codec::Write as _;
796 let over_cap = PACKLIST_MAX_ENTRIES as usize + 1;
797 let mut node = Vec::with_capacity(PACKLIST_HEADER_LEN + 8 + 32 * over_cap);
798 node.extend_from_slice(PACKLIST_MAGIC);
799 node.push(PACKLIST_VERSION);
800 let prev: Option<Hash> = None;
801 prev.write(&mut node);
802 vec![[1u8; 32]; over_cap].write(&mut node);
803 assert_eq!(decode_packlist(&node), Err(PackListError::Malformed));
804
805 // Sanity: the identical construction at exactly the cap is
806 // accepted, so the failure above is the cap check itself, not
807 // an artifact of the hand-rolled encoding.
808 let mut at_cap = Vec::with_capacity(PACKLIST_HEADER_LEN + 8 + 32 * (over_cap - 1));
809 at_cap.extend_from_slice(PACKLIST_MAGIC);
810 at_cap.push(PACKLIST_VERSION);
811 let prev: Option<Hash> = None;
812 prev.write(&mut at_cap);
813 vec![[1u8; 32]; over_cap - 1].write(&mut at_cap);
814 let decoded = decode_packlist(&at_cap).expect("at-cap list must decode");
815 assert_eq!(decoded.packs.len(), PACKLIST_MAX_ENTRIES as usize);
816 }
817
818 #[test]
819 fn plan_first_push_is_self_contained_all_raw() {
820 let (_d, s) = store();
821 let file = put_chunked(&s, &big_buffer());
822 let c1 = commit_with_file(&s, file, vec![], "v1");
823
824 let plan = plan_pack(&s, c1, None).unwrap();
825 assert!(plan.self_contained);
826 assert!(plan.deltas.is_empty(), "no base on a first push");
827 // commit + tree + manifest + every chunk must be present.
828 let full = crate::ops::reachable_objects(&s, &c1).unwrap();
829 assert_eq!(plan.object_count(), full.len());
830 }
831
832 // =================================================================
833 // object_type() short-circuit for plan_pack's type check (#636 /
834 // INV-14) — classification must use the cheap type-prologue check
835 // rather than a full read+verify, while objects actually selected as
836 // delta candidates still get a real, verified read.
837 // =================================================================
838
839 /// Flip a byte well past the 6-byte type prologue so the object's
840 /// on-disk content no longer matches its BLAKE3 hash, while its type
841 /// tag stays intact and `object_type()` still reads it correctly.
842 fn corrupt_payload_byte(s: &ObjectStore, h: &Hash) {
843 use std::fs::OpenOptions;
844 use std::io::{Read, Seek, SeekFrom, Write};
845 let path = s.path_for(h);
846 let mut f = OpenOptions::new()
847 .read(true)
848 .write(true)
849 .open(&path)
850 .unwrap();
851 f.seek(SeekFrom::Start(6)).unwrap();
852 let mut byte = [0u8; 1];
853 f.read_exact(&mut byte).unwrap();
854 f.seek(SeekFrom::Start(6)).unwrap();
855 f.write_all(&[byte[0] ^ 0xFF]).unwrap();
856 f.sync_all().unwrap();
857 }
858
859 #[test]
860 fn plan_pack_first_push_tolerates_corrupted_raw_blob_content() {
861 // First push: every blob in the send-set is `raw` (no delta base
862 // exists yet), so classification never needs to read a blob's
863 // content — only its type. A blob whose payload has bit-rotted
864 // must therefore still plan cleanly and land in `raw`; the actual
865 // pack build is where its (now-failing) integrity check belongs.
866 let (_d, s) = store();
867 let file = put_chunked(&s, &big_buffer());
868 let c1 = commit_with_file(&s, file, vec![], "v1");
869
870 let full = crate::ops::reachable_objects(&s, &c1).unwrap();
871 let blob_hash = *full
872 .iter()
873 .find(|h| s.object_type(h).unwrap() == ObjectType::Blob)
874 .expect("chunked big_buffer must contain at least one blob chunk");
875 corrupt_payload_byte(&s, &blob_hash);
876
877 // Sanity: a full verified read of this object now fails.
878 assert!(matches!(
879 s.read(&blob_hash),
880 Err(StoreError::HashMismatch { .. })
881 ));
882
883 let plan = plan_pack(&s, c1, None).unwrap();
884 assert!(plan.raw.contains(&blob_hash));
885 assert_eq!(plan.object_count(), full.len());
886 }
887
888 #[test]
889 fn plan_pack_second_push_still_verifies_delta_candidate_bytes() {
890 // A blob that IS a delta candidate (paired with a base the remote
891 // holds) must still get a real, verified read — the short-circuit
892 // only removes the *classification* read, not the read needed to
893 // actually build a delta.
894 let (_d, s) = store();
895 let v1 = big_buffer();
896 let file1 = put_chunked(&s, &v1);
897 let c1 = commit_with_file(&s, file1, vec![], "v1");
898
899 let mut v2 = v1.clone();
900 for k in 0..16 {
901 v2[900_000 + k] ^= 0xFF;
902 }
903 let file2 = put_chunked(&s, &v2);
904 let c2 = commit_with_file(&s, file2, vec![c1], "v2");
905
906 let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
907 let target = *bases.keys().next().expect("expected a paired chunk");
908 corrupt_payload_byte(&s, &target);
909
910 let err = plan_pack(&s, c2, Some(c1)).unwrap_err();
911 assert!(matches!(err, StoreError::HashMismatch { .. }));
912 }
913
914 #[test]
915 fn plan_second_push_deltas_changed_chunk_and_skips_unchanged() {
916 let (_d, s) = store();
917 let v1 = big_buffer();
918 let file1 = put_chunked(&s, &v1);
919 let c1 = commit_with_file(&s, file1, vec![], "v1");
920
921 // Edit a small in-place region (same length → stable boundaries).
922 let mut v2 = v1.clone();
923 for k in 0..16 {
924 v2[900_000 + k] ^= 0xFF;
925 }
926 let file2 = put_chunked(&s, &v2);
927 let c2 = commit_with_file(&s, file2, vec![c1], "v2");
928
929 let plan = plan_pack(&s, c2, Some(c1)).unwrap();
930 assert!(
931 !plan.self_contained,
932 "second push diffs against the prior tip"
933 );
934
935 // At least one changed chunk should delta-compress.
936 assert!(
937 !plan.deltas.is_empty(),
938 "expected a delta for the edited chunk"
939 );
940
941 // The send-set must exclude every object the v1 closure already
942 // holds (identical-chunk dedup), so it is far smaller than the full
943 // v2 closure.
944 let v2_full = crate::ops::reachable_objects(&s, &c2).unwrap();
945 assert!(
946 plan.object_count() < v2_full.len(),
947 "unchanged chunks must not be re-sent"
948 );
949
950 // The pack must reconstruct, bit-for-bit, against a store seeded
951 // with the v1 closure (what the remote/fetcher already holds).
952 assert_pack_reconstructs(&s, &plan, c1, c2);
953 }
954
955 /// Build the planned pack, replay it into a fresh store pre-seeded with
956 /// `old_tip`'s closure, and assert every `new_tip` object reconstructs
957 /// with a matching hash.
958 fn assert_pack_reconstructs(src: &ObjectStore, plan: &PackPlan, old_tip: Hash, new_tip: Hash) {
959 let mut w = PackWriter::new();
960 for h in &plan.raw {
961 let bytes = src.read(h).unwrap();
962 w.push_raw(*h, &bytes).unwrap();
963 }
964 for d in &plan.deltas {
965 w.push_delta(&d.base, &d.stream).unwrap();
966 }
967 let pack = w.finish().unwrap();
968
969 // Seed the destination with what the remote already has.
970 let (_d2, dst) = store();
971 for h in crate::ops::reachable_objects(src, &old_tip).unwrap() {
972 dst.write(&src.read(&h).unwrap()).unwrap();
973 }
974
975 PackReader::read(&pack, &dst).unwrap();
976
977 for h in crate::ops::reachable_objects(src, &new_tip).unwrap() {
978 // `dst.read` already re-verifies the object addresses to `h`
979 // (merkle root for Tree/ChunkedBlob, BLAKE3 otherwise); assert
980 // the dispatched id explicitly via `Object::id`.
981 let got = dst.read(&h).unwrap();
982 assert_eq!(
983 crate::serialize::deserialize(&got).unwrap().id().unwrap(),
984 h,
985 "reconstructed object must address to its id"
986 );
987 assert_eq!(got, src.read(&h).unwrap());
988 }
989 }
990
991 #[test]
992 fn select_bases_pairs_only_changed_chunks() {
993 let (_d, s) = store();
994 let v1 = big_buffer();
995 let file1 = put_chunked(&s, &v1);
996 let c1 = commit_with_file(&s, file1, vec![], "v1");
997
998 let mut v2 = v1.clone();
999 v2[1_000_000] ^= 0xFF;
1000 let file2 = put_chunked(&s, &v2);
1001 let c2 = commit_with_file(&s, file2, vec![c1], "v2");
1002
1003 let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
1004 assert!(!bases.is_empty());
1005 // Every paired target is a chunk that is new in v2, and every base
1006 // is a chunk that v1 held.
1007 let v1_set = crate::ops::reachable_objects(&s, &c1).unwrap();
1008 for (target, base) in &bases {
1009 assert!(!v1_set.contains(target), "target should be new");
1010 assert!(v1_set.contains(base), "base must be present at old tip");
1011 }
1012 }
1013
1014 /// Deterministic, feature-rich chunk content (xorshift so 16-byte window
1015 /// hashes are well-distributed rather than colliding on flat data).
1016 fn chunk_content(seed: u8, len: usize) -> Vec<u8> {
1017 let mut state = u64::from(seed).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
1018 let mut v = vec![0u8; len];
1019 for byte in &mut v {
1020 state ^= state << 13;
1021 state ^= state >> 7;
1022 state ^= state << 17;
1023 *byte = (state & 0xff) as u8;
1024 }
1025 v
1026 }
1027
1028 fn put_blob(s: &ObjectStore, content: Vec<u8>) -> Hash {
1029 put(s, &Object::Blob(Blob { data: content }))
1030 }
1031
1032 /// Build a `ChunkedBlob` from explicit chunk contents (bypassing
1033 /// `FastCDC`) so a test can control chunk boundaries and shifts precisely.
1034 fn put_chunked_from(s: &ObjectStore, chunks: &[Vec<u8>]) -> Hash {
1035 let total: usize = chunks.iter().map(Vec::len).sum();
1036 let chunk_ids: Vec<Hash> = chunks.iter().map(|c| put_blob(s, c.clone())).collect();
1037 put(
1038 s,
1039 &Object::ChunkedBlob(ChunkedBlob {
1040 total_size: total as u64,
1041 chunk_size: 0,
1042 chunks: chunk_ids,
1043 }),
1044 )
1045 }
1046
1047 #[test]
1048 #[allow(clippy::many_single_char_names)] // a..e keep the chunk-shift table compact
1049 fn content_aware_pairing_picks_similar_base_after_a_shift() {
1050 let (_d, s) = store();
1051 let (a, b, c, d, e) = (
1052 chunk_content(1, 400),
1053 chunk_content(2, 400),
1054 chunk_content(3, 400),
1055 chunk_content(4, 400),
1056 chunk_content(5, 400),
1057 );
1058 let mut e_mod = e.clone();
1059 e_mod[200] ^= 0xFF; // a one-byte edit to E
1060
1061 // Old file = [A,B,C,D,E]; new file = [B,C,D,E'] — A deleted (so every
1062 // surviving chunk's index shifts down by one) and E modified. Counts
1063 // differ, so the content-similarity path runs.
1064 let old_file = put_chunked_from(&s, &[a, b.clone(), c.clone(), d.clone(), e.clone()]);
1065 let new_file = put_chunked_from(&s, &[b, c, d.clone(), e_mod.clone()]);
1066 let c_old = commit_with_file(&s, old_file, vec![], "old");
1067 let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
1068
1069 let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
1070
1071 let e_mod_id = put_blob(&s, e_mod);
1072 let e_id = put_blob(&s, e);
1073 let d_id = put_blob(&s, d);
1074
1075 // E' is the only changed chunk. It sits at new index 3; the old chunk
1076 // at index 3 is D, so position-clamp pairing would mispair E'→D.
1077 // Content similarity must instead pair E'→E (its real prior version).
1078 assert_eq!(
1079 bases.get(&e_mod_id),
1080 Some(&e_id),
1081 "E' must delta against E (content match), not the wrong-index D"
1082 );
1083 assert_ne!(bases.get(&e_mod_id), Some(&d_id));
1084 }
1085
1086 #[test]
1087 #[allow(clippy::many_single_char_names)] // a..d + z keep the chunk table compact
1088 fn shifted_pairing_skips_a_chunk_with_no_content_overlap() {
1089 // After a shift (differing counts), a brand-new chunk dissimilar to
1090 // every old chunk must NOT be paired — no position/clamp fallback.
1091 let (_d, s) = store();
1092 let (a, b, c, d) = (
1093 chunk_content(1, 400),
1094 chunk_content(2, 400),
1095 chunk_content(3, 400),
1096 chunk_content(4, 400),
1097 );
1098 let z = chunk_content(99, 400); // unrelated to a/b/c/d
1099 // old = [A,B,C,D]; new = [B,C,Z] — counts differ (content path), and Z
1100 // is new + dissimilar to every old chunk.
1101 let old_file = put_chunked_from(&s, &[a, b.clone(), c.clone(), d]);
1102 let new_file = put_chunked_from(&s, &[b, c, z.clone()]);
1103 let c_old = commit_with_file(&s, old_file, vec![], "old");
1104 let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
1105
1106 let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
1107 let z_id = put_blob(&s, z);
1108 assert!(
1109 !bases.contains_key(&z_id),
1110 "a dissimilar new chunk must be left unpaired (sent raw), not clamped to a base"
1111 );
1112 }
1113
1114 #[test]
1115 fn equal_count_edit_still_uses_fast_index_path() {
1116 // Same chunk count ⇒ no shift ⇒ same-index pairing (no content reads).
1117 let (_d, s) = store();
1118 let (a, b, c) = (
1119 chunk_content(10, 400),
1120 chunk_content(11, 400),
1121 chunk_content(12, 400),
1122 );
1123 let mut b_mod = b.clone();
1124 b_mod[50] ^= 0xFF;
1125 let old_file = put_chunked_from(&s, &[a.clone(), b.clone(), c.clone()]);
1126 let new_file = put_chunked_from(&s, &[a, b_mod.clone(), c]);
1127 let c_old = commit_with_file(&s, old_file, vec![], "old");
1128 let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
1129
1130 let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
1131 let b_mod_id = put_blob(&s, b_mod);
1132 let b_id = put_blob(&s, b);
1133 assert_eq!(
1134 bases.get(&b_mod_id),
1135 Some(&b_id),
1136 "in-place edit pairs by index"
1137 );
1138 }
1139
1140 // =================================================================
1141 // #646a — small (non-chunked) blob delta pairing. `pair_entry` must
1142 // pair a changed `Blob<->Blob` same-path entry (previously a no-op),
1143 // using the old blob as the delta base for the new one, without
1144 // touching cross-path or `Blob<->ChunkedBlob` transitions.
1145 // =================================================================
1146
1147 #[test]
1148 fn small_blob_edit_is_delta_paired_against_same_path_prior_version() {
1149 let (_d, s) = store();
1150 // Small, well under CHUNK_THRESHOLD: a single Blob object, not a
1151 // ChunkedBlob manifest. Large enough (many unchanged lines around a
1152 // single mid-file edit) that a delta's fixed per-instruction
1153 // overhead (SPEC-DELTA header + two COPYs + one INSERT + the
1154 // HASH_LEN base pointer) is comfortably smaller than the raw blob.
1155 let mut v1 = Vec::new();
1156 for i in 0..40 {
1157 v1.extend_from_slice(format!("unchanged line number {i:02}\n").as_bytes());
1158 }
1159 let mut v2 = v1.clone();
1160 // Replace exactly one line in the middle with a different one,
1161 // keeping everything before/after byte-identical to v1.
1162 let needle = b"unchanged line number 20\n".to_vec();
1163 let pos = v2
1164 .windows(needle.len())
1165 .position(|w| w == needle.as_slice())
1166 .unwrap();
1167 v2.splice(
1168 pos..pos + needle.len(),
1169 b"THIS LINE WAS EDITED\n".iter().copied(),
1170 );
1171
1172 let blob1 = put_blob(&s, v1.clone());
1173 let blob2 = put_blob(&s, v2.clone());
1174 let c1 = commit_with_named_file(&s, b"small.txt", blob1, vec![], "v1");
1175 let c2 = commit_with_named_file(&s, b"small.txt", blob2, vec![c1], "v2");
1176
1177 let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
1178 assert_eq!(
1179 bases.get(&blob2),
1180 Some(&blob1),
1181 "same-path small-blob edit must pair against the prior version"
1182 );
1183
1184 // The pairing must actually be usable by plan_pack: it should
1185 // produce a delta for blob2, and that delta must be strictly
1186 // smaller on the wire than sending blob2 raw.
1187 let plan = plan_pack(&s, c2, Some(c1)).unwrap();
1188 let planned = plan
1189 .deltas
1190 .iter()
1191 .find(|d| d.target == blob2)
1192 .expect("expected a planned delta for the edited small blob");
1193 assert_eq!(planned.base, blob1);
1194 assert!(
1195 hash::HASH_LEN + planned.stream.len() < v2.len(),
1196 "delta payload must beat sending the new blob raw"
1197 );
1198 assert!(
1199 !plan.raw.contains(&blob2),
1200 "the edited blob should not also be sent raw"
1201 );
1202
1203 assert_pack_reconstructs(&s, &plan, c1, c2);
1204 }
1205
1206 #[test]
1207 fn small_blob_pairing_is_strictly_same_path() {
1208 // Two unrelated paths, each holding a small blob, where the "new"
1209 // path's content is a near-duplicate of the "old" path's content.
1210 // Despite the content similarity, pairing is same-path only — a
1211 // blob at a path with no old-side counterpart must never be paired
1212 // against a differently-named entry's prior blob.
1213 let (_d, s) = store();
1214 let a_v1 = b"shared prefix content for path a\n".to_vec();
1215 let mut a_v2 = a_v1.clone();
1216 a_v2.push(b'!');
1217
1218 let blob_a1 = put_blob(&s, a_v1.clone());
1219 let blob_a2 = put_blob(&s, a_v2);
1220 let c1 = commit_with_named_file(&s, b"a.txt", blob_a1, vec![], "v1");
1221
1222 // c2 changes "a.txt" AND introduces a brand-new path "b.txt" whose
1223 // content is near-identical to a.txt's OLD content (but it is a
1224 // distinct path with no old-side entry at all).
1225 let blob_b = put_blob(&s, a_v1);
1226 let tree2 = put(
1227 &s,
1228 &Object::Tree(Tree {
1229 entries: vec![
1230 TreeEntry {
1231 name: b"a.txt".to_vec(),
1232 mode: EntryMode::Blob,
1233 object_hash: blob_a2,
1234 },
1235 TreeEntry {
1236 name: b"b.txt".to_vec(),
1237 mode: EntryMode::Blob,
1238 object_hash: blob_b,
1239 },
1240 ],
1241 }),
1242 );
1243 let c2 = put(
1244 &s,
1245 &Object::Commit(Commit::new_unannotated(
1246 tree2,
1247 vec![c1],
1248 Identity::ed25519([7; 32]),
1249 [0; 32],
1250 b"v2".to_vec(),
1251 2,
1252 [0; 64],
1253 )),
1254 );
1255
1256 let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
1257 assert_eq!(
1258 bases.get(&blob_a2),
1259 Some(&blob_a1),
1260 "a.txt's edit still pairs against its own prior version"
1261 );
1262 assert!(
1263 !bases.contains_key(&blob_b),
1264 "b.txt has no old-side counterpart and must not be paired at all, \
1265 even against a content-similar blob from a different path"
1266 );
1267 }
1268}
1269
1270#[cfg(test)]
1271mod wire_conformance {
1272 use super::*;
1273
1274 /// Conformance pin: the exact on-wire bytes of a `PackListNode`. Locks
1275 /// the `commonware-codec` body encoding (`MKPL` + version guard, then a
1276 /// codec `Option<Hash>` and a length-prefixed `Vec<Hash>`) so a
1277 /// commonware version bump that silently changes the encoding is caught
1278 /// here rather than in the field. Regenerate deliberately only on an
1279 /// intentional format change (and bump `PACKLIST_VERSION`).
1280 #[test]
1281 fn packlist_wire_format_is_pinned() {
1282 let bytes = encode_packlist(Some([0x11u8; 32]), &[[0x22u8; 32], [0x33u8; 32]]).unwrap();
1283 let expected = "4d4b504c0101\
1284 1111111111111111111111111111111111111111111111111111111111111111\
1285 02\
1286 2222222222222222222222222222222222222222222222222222222222222222\
1287 3333333333333333333333333333333333333333333333333333333333333333";
1288 assert_eq!(
1289 hash::to_hex_bytes(&bytes),
1290 expected,
1291 "PackListNode wire format drifted (commonware-codec change?)"
1292 );
1293 let node = decode_packlist(&bytes).unwrap();
1294 assert_eq!(node.prev, Some([0x11u8; 32]));
1295 assert_eq!(node.packs, vec![[0x22u8; 32], [0x33u8; 32]]);
1296 }
1297}