Skip to main content

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}