use std::collections::{BTreeSet, HashMap, HashSet};
use crate::delta;
use crate::hash::{self, Hash};
use crate::object::{Object, ObjectType};
use crate::store::{ObjectStore, StoreError};
pub const PACKLIST_MAGIC: &[u8; 4] = b"MKPL";
pub const PACKLIST_VERSION: u8 = 1;
pub const PACKLIST_MAX_ENTRIES: u32 = 1_000_000;
const PACKLIST_HEADER_LEN: usize = 4 + 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackListNode {
pub prev: Option<Hash>,
pub packs: Vec<Hash>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PackListError {
#[error("packlist is shorter than the {PACKLIST_HEADER_LEN}-byte magic+version header")]
TooShort,
#[error("packlist magic is not \"MKPL\"")]
InvalidMagic,
#[error("packlist version {0} is not supported (v1 only)")]
UnsupportedVersion(u8),
#[error("packlist pack count exceeds the {PACKLIST_MAX_ENTRIES} cap")]
TooManyEntries(u32),
#[error("packlist body is malformed (bad codec payload or trailing bytes)")]
Malformed,
}
pub fn encode_packlist(prev: Option<Hash>, packs: &[Hash]) -> Result<Vec<u8>, PackListError> {
use commonware_codec::Write;
let count = u32::try_from(packs.len()).map_err(|_| PackListError::TooManyEntries(u32::MAX))?;
if count > PACKLIST_MAX_ENTRIES {
return Err(PackListError::TooManyEntries(count));
}
let mut out = Vec::new();
out.extend_from_slice(PACKLIST_MAGIC);
out.push(PACKLIST_VERSION);
prev.write(&mut out);
packs.to_vec().write(&mut out);
Ok(out)
}
pub fn decode_packlist(bytes: &[u8]) -> Result<PackListNode, PackListError> {
use bytes::Buf as _;
use commonware_codec::{ReadExt, ReadRangeExt};
if bytes.len() < PACKLIST_HEADER_LEN {
return Err(PackListError::TooShort);
}
if &bytes[..4] != PACKLIST_MAGIC.as_slice() {
return Err(PackListError::InvalidMagic);
}
let version = bytes[4];
if version != PACKLIST_VERSION {
return Err(PackListError::UnsupportedVersion(version));
}
let mut buf: &[u8] = &bytes[PACKLIST_HEADER_LEN..];
let prev = <Option<Hash>>::read(&mut buf).map_err(|_| PackListError::Malformed)?;
let packs = <Vec<Hash>>::read_range(&mut buf, 0..=PACKLIST_MAX_ENTRIES as usize)
.map_err(|_| PackListError::Malformed)?;
if buf.has_remaining() {
return Err(PackListError::Malformed);
}
Ok(PackListNode { prev, packs })
}
const MAX_TREE_DEPTH: usize = 64;
pub fn select_chunk_delta_bases(
store: &ObjectStore,
new_tip: Hash,
old_tip: Hash,
) -> Result<HashMap<Hash, Hash>, StoreError> {
let mut out = HashMap::new();
let (Some(new_tree), Some(old_tree)) = (tip_tree(store, new_tip)?, tip_tree(store, old_tip)?)
else {
return Ok(out);
};
pair_trees(store, new_tree, old_tree, 0, &mut out)?;
Ok(out)
}
fn tip_tree(store: &ObjectStore, tip: Hash) -> Result<Option<Hash>, StoreError> {
match store.read_object(&tip) {
Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
Ok(_) | Err(StoreError::ObjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
fn pair_trees(
store: &ObjectStore,
new_tree: Hash,
old_tree: Hash,
depth: usize,
out: &mut HashMap<Hash, Hash>,
) -> Result<(), StoreError> {
if depth > MAX_TREE_DEPTH || new_tree == old_tree {
return Ok(());
}
let (Some(Object::Tree(new_t)), Some(Object::Tree(old_t))) = (
read_optional(store, new_tree)?,
read_optional(store, old_tree)?,
) else {
return Ok(());
};
let (mut i, mut j) = (0usize, 0usize);
while i < new_t.entries.len() && j < old_t.entries.len() {
let ne = &new_t.entries[i];
let oe = &old_t.entries[j];
match ne.name.cmp(&oe.name) {
std::cmp::Ordering::Less => i += 1,
std::cmp::Ordering::Greater => j += 1,
std::cmp::Ordering::Equal => {
if ne.object_hash != oe.object_hash {
pair_entry(store, ne.object_hash, oe.object_hash, depth, out)?;
}
i += 1;
j += 1;
}
}
}
Ok(())
}
fn pair_entry(
store: &ObjectStore,
new_hash: Hash,
old_hash: Hash,
depth: usize,
out: &mut HashMap<Hash, Hash>,
) -> Result<(), StoreError> {
match (store.read_object(&new_hash), store.read_object(&old_hash)) {
(Ok(Object::Tree(_)), Ok(Object::Tree(_))) => {
pair_trees(store, new_hash, old_hash, depth + 1, out)
}
(Ok(Object::ChunkedBlob(new_cb)), Ok(Object::ChunkedBlob(old_cb))) => {
pair_chunks(store, &new_cb.chunks, &old_cb.chunks, out)
}
(Ok(Object::Blob(_)), Ok(Object::Blob(_))) => {
out.entry(new_hash).or_insert(old_hash);
Ok(())
}
(Err(StoreError::ObjectNotFound(_)), _) | (_, Err(StoreError::ObjectNotFound(_))) => Ok(()),
(Err(e), _) | (_, Err(e)) => Err(e),
_ => Ok(()),
}
}
const FEATURE_WINDOW: usize = 16;
const FEATURE_COUNT: usize = 4;
fn pair_chunks(
store: &ObjectStore,
new_chunks: &[Hash],
old_chunks: &[Hash],
out: &mut HashMap<Hash, Hash>,
) -> Result<(), StoreError> {
if old_chunks.is_empty() {
return Ok(());
}
let old_set: HashSet<&Hash> = old_chunks.iter().collect();
if new_chunks.len() == old_chunks.len() {
for (j, nj) in new_chunks.iter().enumerate() {
if old_set.contains(nj) || out.contains_key(nj) {
continue;
}
if old_chunks[j] != *nj {
out.insert(*nj, old_chunks[j]);
}
}
return Ok(());
}
let mut feature_index: HashMap<u64, Vec<Hash>> = HashMap::new();
for oc in old_chunks {
if let Some(content) = chunk_bytes(store, oc)? {
for f in chunk_features(&content) {
feature_index.entry(f).or_default().push(*oc);
}
}
}
for nj in new_chunks {
if old_set.contains(nj) || out.contains_key(nj) {
continue;
}
let Some(content) = chunk_bytes(store, nj)? else {
continue;
};
let mut votes: HashMap<Hash, u32> = HashMap::new();
for f in &chunk_features(&content) {
if let Some(cands) = feature_index.get(f) {
for cand in cands {
*votes.entry(*cand).or_default() += 1;
}
}
}
if let Some((base, _)) = votes
.into_iter()
.filter(|(cand, _)| cand != nj)
.max_by(|x, y| x.1.cmp(&y.1).then_with(|| y.0.cmp(&x.0)))
{
out.insert(*nj, base);
}
}
Ok(())
}
fn chunk_bytes(store: &ObjectStore, h: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
match store.read_object(h) {
Ok(Object::Blob(b)) => Ok(Some(b.data)),
Ok(_) | Err(StoreError::ObjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
fn chunk_features(bytes: &[u8]) -> Vec<u64> {
if bytes.len() < FEATURE_WINDOW {
return Vec::new();
}
let mut best: Vec<u64> = Vec::with_capacity(FEATURE_COUNT + 1);
for w in bytes.windows(FEATURE_WINDOW) {
let h = fnv1a(w);
if best.len() == FEATURE_COUNT && h >= best[FEATURE_COUNT - 1] {
continue;
}
if let Err(pos) = best.binary_search(&h) {
best.insert(pos, h);
best.truncate(FEATURE_COUNT);
}
}
best
}
fn fnv1a(block: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in block {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0001_0000_01b3);
}
h
}
fn read_optional(store: &ObjectStore, h: Hash) -> Result<Option<Object>, StoreError> {
match store.read_object(&h) {
Ok(o) => Ok(Some(o)),
Err(StoreError::ObjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
#[derive(Debug, Clone)]
pub struct PlannedDelta {
pub target: Hash,
pub base: Hash,
pub stream: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct PackPlan {
pub raw: Vec<Hash>,
pub deltas: Vec<PlannedDelta>,
pub self_contained: bool,
}
impl PackPlan {
#[must_use]
pub fn object_count(&self) -> usize {
self.raw.len() + self.deltas.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.raw.is_empty() && self.deltas.is_empty()
}
}
pub fn plan_pack(
store: &ObjectStore,
new_tip: Hash,
old_tip: Option<Hash>,
) -> Result<PackPlan, StoreError> {
let new_set = crate::ops::reachable_objects(store, &new_tip)?;
let mut remote_set = BTreeSet::new();
let mut base_map = HashMap::new();
let mut have_old = false;
if let Some(o) = old_tip
&& store.contains(&o)
{
match crate::ops::reachable_objects(store, &o) {
Ok(s) => {
remote_set = s;
base_map = select_chunk_delta_bases(store, new_tip, o)?;
have_old = true;
}
Err(StoreError::ObjectNotFound(_)) => {}
Err(e) => return Err(e),
}
}
let send: BTreeSet<Hash> = new_set.difference(&remote_set).copied().collect();
let mut non_blob_raw = Vec::new();
let mut blob_raw = Vec::new();
let mut deltas = Vec::new();
for h in &send {
let is_blob = store.object_type(h)? == ObjectType::Blob;
if is_blob
&& let Some(base) = base_map.get(h)
&& remote_set.contains(base)
{
let bytes = store.read(h)?;
if let Some(planned) = try_delta(store, *h, *base, &bytes)? {
deltas.push(planned);
continue;
}
}
if is_blob {
blob_raw.push(*h);
} else {
non_blob_raw.push(*h);
}
}
let mut raw = non_blob_raw;
raw.extend(blob_raw);
Ok(PackPlan {
raw,
deltas,
self_contained: !have_old,
})
}
fn try_delta(
store: &ObjectStore,
target: Hash,
base: Hash,
target_bytes: &[u8],
) -> Result<Option<PlannedDelta>, StoreError> {
let base_bytes = store.read(&base)?;
let Ok(stream) = delta::encode(&base_bytes, target_bytes) else {
return Ok(None);
};
if hash::HASH_LEN + stream.len() < target_bytes.len() {
Ok(Some(PlannedDelta {
target,
base,
stream,
}))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunker::{ChunkIterator, FastCdc};
use crate::object::{Blob, ChunkedBlob, Commit, EntryMode, Identity, Tree, TreeEntry};
use crate::pack::{PackReader, PackWriter};
use crate::serialize;
use tempfile::TempDir;
fn store() -> (TempDir, ObjectStore) {
let d = TempDir::new().unwrap();
let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
(d, s)
}
fn put(s: &ObjectStore, obj: &Object) -> Hash {
s.write(&serialize::serialize(obj).unwrap()).unwrap()
}
fn put_chunked(s: &ObjectStore, data: &[u8]) -> Hash {
let chunks: Vec<Hash> = ChunkIterator::new(FastCdc::v1(), data)
.map(|b| {
put(
s,
&Object::Blob(Blob {
data: data[b.offset..b.offset + b.length].to_vec(),
}),
)
})
.collect();
put(
s,
&Object::ChunkedBlob(ChunkedBlob {
total_size: data.len() as u64,
chunk_size: 0,
chunks,
}),
)
}
fn commit_with_file(s: &ObjectStore, file_hash: Hash, parents: Vec<Hash>, msg: &str) -> Hash {
commit_with_named_file(s, b"big.bin", file_hash, parents, msg)
}
fn commit_with_named_file(
s: &ObjectStore,
name: &[u8],
file_hash: Hash,
parents: Vec<Hash>,
msg: &str,
) -> Hash {
let tree = put(
s,
&Object::Tree(Tree {
entries: vec![TreeEntry {
name: name.to_vec(),
mode: EntryMode::Blob,
object_hash: file_hash,
}],
}),
);
put(
s,
&Object::Commit(Commit::new_unannotated(
tree,
parents,
Identity::ed25519([7; 32]),
[0; 32],
msg.as_bytes().to_vec(),
msg.len() as u64,
[0; 64],
)),
)
}
fn big_buffer() -> Vec<u8> {
let mut data = vec![0u8; 2 * 1024 * 1024];
let mut state: u64 = 0x1234_5678_9abc_def0;
for chunk in data.chunks_mut(8) {
state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^= z >> 31;
let bytes = z.to_le_bytes();
let n = chunk.len();
chunk.copy_from_slice(&bytes[..n]);
}
data
}
#[test]
fn packlist_node_roundtrip_with_prev() {
let prev = [0x11u8; 32];
let packs = vec![[1u8; 32], [2u8; 32], [0xABu8; 32]];
let bytes = encode_packlist(Some(prev), &packs).unwrap();
assert_eq!(&bytes[..4], PACKLIST_MAGIC);
assert_eq!(bytes[4], PACKLIST_VERSION);
let node = decode_packlist(&bytes).unwrap();
assert_eq!(node.prev, Some(prev));
assert_eq!(node.packs, packs);
}
#[test]
fn packlist_node_roundtrip_first_node() {
let bytes = encode_packlist(None, &[[7u8; 32]]).unwrap();
let node = decode_packlist(&bytes).unwrap();
assert_eq!(node.prev, None);
assert_eq!(node.packs, vec![[7u8; 32]]);
}
#[test]
fn packlist_rejects_bad_magic_version_body_and_length() {
let good = encode_packlist(Some([0x11u8; 32]), &[[9u8; 32]]).unwrap();
let mut bad_magic = good.clone();
bad_magic[0] = b'X';
assert_eq!(
decode_packlist(&bad_magic),
Err(PackListError::InvalidMagic)
);
let mut bad_ver = good.clone();
bad_ver[4] = 2;
assert_eq!(
decode_packlist(&bad_ver),
Err(PackListError::UnsupportedVersion(2))
);
let mut short = good.clone();
short.pop();
assert_eq!(decode_packlist(&short), Err(PackListError::Malformed));
let mut long = good;
long.push(0);
assert_eq!(decode_packlist(&long), Err(PackListError::Malformed));
assert_eq!(decode_packlist(&[0u8; 3]), Err(PackListError::TooShort));
}
#[test]
fn packlist_decode_rejects_over_cap_pack_list() {
use commonware_codec::Write as _;
let over_cap = PACKLIST_MAX_ENTRIES as usize + 1;
let mut node = Vec::with_capacity(PACKLIST_HEADER_LEN + 8 + 32 * over_cap);
node.extend_from_slice(PACKLIST_MAGIC);
node.push(PACKLIST_VERSION);
let prev: Option<Hash> = None;
prev.write(&mut node);
vec![[1u8; 32]; over_cap].write(&mut node);
assert_eq!(decode_packlist(&node), Err(PackListError::Malformed));
let mut at_cap = Vec::with_capacity(PACKLIST_HEADER_LEN + 8 + 32 * (over_cap - 1));
at_cap.extend_from_slice(PACKLIST_MAGIC);
at_cap.push(PACKLIST_VERSION);
let prev: Option<Hash> = None;
prev.write(&mut at_cap);
vec![[1u8; 32]; over_cap - 1].write(&mut at_cap);
let decoded = decode_packlist(&at_cap).expect("at-cap list must decode");
assert_eq!(decoded.packs.len(), PACKLIST_MAX_ENTRIES as usize);
}
#[test]
fn plan_first_push_is_self_contained_all_raw() {
let (_d, s) = store();
let file = put_chunked(&s, &big_buffer());
let c1 = commit_with_file(&s, file, vec![], "v1");
let plan = plan_pack(&s, c1, None).unwrap();
assert!(plan.self_contained);
assert!(plan.deltas.is_empty(), "no base on a first push");
let full = crate::ops::reachable_objects(&s, &c1).unwrap();
assert_eq!(plan.object_count(), full.len());
}
fn corrupt_payload_byte(s: &ObjectStore, h: &Hash) {
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
let path = s.path_for(h);
let mut f = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
f.seek(SeekFrom::Start(6)).unwrap();
let mut byte = [0u8; 1];
f.read_exact(&mut byte).unwrap();
f.seek(SeekFrom::Start(6)).unwrap();
f.write_all(&[byte[0] ^ 0xFF]).unwrap();
f.sync_all().unwrap();
}
#[test]
fn plan_pack_first_push_tolerates_corrupted_raw_blob_content() {
let (_d, s) = store();
let file = put_chunked(&s, &big_buffer());
let c1 = commit_with_file(&s, file, vec![], "v1");
let full = crate::ops::reachable_objects(&s, &c1).unwrap();
let blob_hash = *full
.iter()
.find(|h| s.object_type(h).unwrap() == ObjectType::Blob)
.expect("chunked big_buffer must contain at least one blob chunk");
corrupt_payload_byte(&s, &blob_hash);
assert!(matches!(
s.read(&blob_hash),
Err(StoreError::HashMismatch { .. })
));
let plan = plan_pack(&s, c1, None).unwrap();
assert!(plan.raw.contains(&blob_hash));
assert_eq!(plan.object_count(), full.len());
}
#[test]
fn plan_pack_second_push_still_verifies_delta_candidate_bytes() {
let (_d, s) = store();
let v1 = big_buffer();
let file1 = put_chunked(&s, &v1);
let c1 = commit_with_file(&s, file1, vec![], "v1");
let mut v2 = v1.clone();
for k in 0..16 {
v2[900_000 + k] ^= 0xFF;
}
let file2 = put_chunked(&s, &v2);
let c2 = commit_with_file(&s, file2, vec![c1], "v2");
let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
let target = *bases.keys().next().expect("expected a paired chunk");
corrupt_payload_byte(&s, &target);
let err = plan_pack(&s, c2, Some(c1)).unwrap_err();
assert!(matches!(err, StoreError::HashMismatch { .. }));
}
#[test]
fn plan_second_push_deltas_changed_chunk_and_skips_unchanged() {
let (_d, s) = store();
let v1 = big_buffer();
let file1 = put_chunked(&s, &v1);
let c1 = commit_with_file(&s, file1, vec![], "v1");
let mut v2 = v1.clone();
for k in 0..16 {
v2[900_000 + k] ^= 0xFF;
}
let file2 = put_chunked(&s, &v2);
let c2 = commit_with_file(&s, file2, vec![c1], "v2");
let plan = plan_pack(&s, c2, Some(c1)).unwrap();
assert!(
!plan.self_contained,
"second push diffs against the prior tip"
);
assert!(
!plan.deltas.is_empty(),
"expected a delta for the edited chunk"
);
let v2_full = crate::ops::reachable_objects(&s, &c2).unwrap();
assert!(
plan.object_count() < v2_full.len(),
"unchanged chunks must not be re-sent"
);
assert_pack_reconstructs(&s, &plan, c1, c2);
}
fn assert_pack_reconstructs(src: &ObjectStore, plan: &PackPlan, old_tip: Hash, new_tip: Hash) {
let mut w = PackWriter::new();
for h in &plan.raw {
let bytes = src.read(h).unwrap();
w.push_raw(*h, &bytes).unwrap();
}
for d in &plan.deltas {
w.push_delta(&d.base, &d.stream).unwrap();
}
let pack = w.finish().unwrap();
let (_d2, dst) = store();
for h in crate::ops::reachable_objects(src, &old_tip).unwrap() {
dst.write(&src.read(&h).unwrap()).unwrap();
}
PackReader::read(&pack, &dst).unwrap();
for h in crate::ops::reachable_objects(src, &new_tip).unwrap() {
let got = dst.read(&h).unwrap();
assert_eq!(
crate::serialize::deserialize(&got).unwrap().id().unwrap(),
h,
"reconstructed object must address to its id"
);
assert_eq!(got, src.read(&h).unwrap());
}
}
#[test]
fn select_bases_pairs_only_changed_chunks() {
let (_d, s) = store();
let v1 = big_buffer();
let file1 = put_chunked(&s, &v1);
let c1 = commit_with_file(&s, file1, vec![], "v1");
let mut v2 = v1.clone();
v2[1_000_000] ^= 0xFF;
let file2 = put_chunked(&s, &v2);
let c2 = commit_with_file(&s, file2, vec![c1], "v2");
let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
assert!(!bases.is_empty());
let v1_set = crate::ops::reachable_objects(&s, &c1).unwrap();
for (target, base) in &bases {
assert!(!v1_set.contains(target), "target should be new");
assert!(v1_set.contains(base), "base must be present at old tip");
}
}
fn chunk_content(seed: u8, len: usize) -> Vec<u8> {
let mut state = u64::from(seed).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut v = vec![0u8; len];
for byte in &mut v {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
*byte = (state & 0xff) as u8;
}
v
}
fn put_blob(s: &ObjectStore, content: Vec<u8>) -> Hash {
put(s, &Object::Blob(Blob { data: content }))
}
fn put_chunked_from(s: &ObjectStore, chunks: &[Vec<u8>]) -> Hash {
let total: usize = chunks.iter().map(Vec::len).sum();
let chunk_ids: Vec<Hash> = chunks.iter().map(|c| put_blob(s, c.clone())).collect();
put(
s,
&Object::ChunkedBlob(ChunkedBlob {
total_size: total as u64,
chunk_size: 0,
chunks: chunk_ids,
}),
)
}
#[test]
#[allow(clippy::many_single_char_names)] fn content_aware_pairing_picks_similar_base_after_a_shift() {
let (_d, s) = store();
let (a, b, c, d, e) = (
chunk_content(1, 400),
chunk_content(2, 400),
chunk_content(3, 400),
chunk_content(4, 400),
chunk_content(5, 400),
);
let mut e_mod = e.clone();
e_mod[200] ^= 0xFF;
let old_file = put_chunked_from(&s, &[a, b.clone(), c.clone(), d.clone(), e.clone()]);
let new_file = put_chunked_from(&s, &[b, c, d.clone(), e_mod.clone()]);
let c_old = commit_with_file(&s, old_file, vec![], "old");
let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
let e_mod_id = put_blob(&s, e_mod);
let e_id = put_blob(&s, e);
let d_id = put_blob(&s, d);
assert_eq!(
bases.get(&e_mod_id),
Some(&e_id),
"E' must delta against E (content match), not the wrong-index D"
);
assert_ne!(bases.get(&e_mod_id), Some(&d_id));
}
#[test]
#[allow(clippy::many_single_char_names)] fn shifted_pairing_skips_a_chunk_with_no_content_overlap() {
let (_d, s) = store();
let (a, b, c, d) = (
chunk_content(1, 400),
chunk_content(2, 400),
chunk_content(3, 400),
chunk_content(4, 400),
);
let z = chunk_content(99, 400); let old_file = put_chunked_from(&s, &[a, b.clone(), c.clone(), d]);
let new_file = put_chunked_from(&s, &[b, c, z.clone()]);
let c_old = commit_with_file(&s, old_file, vec![], "old");
let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
let z_id = put_blob(&s, z);
assert!(
!bases.contains_key(&z_id),
"a dissimilar new chunk must be left unpaired (sent raw), not clamped to a base"
);
}
#[test]
fn equal_count_edit_still_uses_fast_index_path() {
let (_d, s) = store();
let (a, b, c) = (
chunk_content(10, 400),
chunk_content(11, 400),
chunk_content(12, 400),
);
let mut b_mod = b.clone();
b_mod[50] ^= 0xFF;
let old_file = put_chunked_from(&s, &[a.clone(), b.clone(), c.clone()]);
let new_file = put_chunked_from(&s, &[a, b_mod.clone(), c]);
let c_old = commit_with_file(&s, old_file, vec![], "old");
let c_new = commit_with_file(&s, new_file, vec![c_old], "new");
let bases = select_chunk_delta_bases(&s, c_new, c_old).unwrap();
let b_mod_id = put_blob(&s, b_mod);
let b_id = put_blob(&s, b);
assert_eq!(
bases.get(&b_mod_id),
Some(&b_id),
"in-place edit pairs by index"
);
}
#[test]
fn small_blob_edit_is_delta_paired_against_same_path_prior_version() {
let (_d, s) = store();
let mut v1 = Vec::new();
for i in 0..40 {
v1.extend_from_slice(format!("unchanged line number {i:02}\n").as_bytes());
}
let mut v2 = v1.clone();
let needle = b"unchanged line number 20\n".to_vec();
let pos = v2
.windows(needle.len())
.position(|w| w == needle.as_slice())
.unwrap();
v2.splice(
pos..pos + needle.len(),
b"THIS LINE WAS EDITED\n".iter().copied(),
);
let blob1 = put_blob(&s, v1.clone());
let blob2 = put_blob(&s, v2.clone());
let c1 = commit_with_named_file(&s, b"small.txt", blob1, vec![], "v1");
let c2 = commit_with_named_file(&s, b"small.txt", blob2, vec![c1], "v2");
let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
assert_eq!(
bases.get(&blob2),
Some(&blob1),
"same-path small-blob edit must pair against the prior version"
);
let plan = plan_pack(&s, c2, Some(c1)).unwrap();
let planned = plan
.deltas
.iter()
.find(|d| d.target == blob2)
.expect("expected a planned delta for the edited small blob");
assert_eq!(planned.base, blob1);
assert!(
hash::HASH_LEN + planned.stream.len() < v2.len(),
"delta payload must beat sending the new blob raw"
);
assert!(
!plan.raw.contains(&blob2),
"the edited blob should not also be sent raw"
);
assert_pack_reconstructs(&s, &plan, c1, c2);
}
#[test]
fn small_blob_pairing_is_strictly_same_path() {
let (_d, s) = store();
let a_v1 = b"shared prefix content for path a\n".to_vec();
let mut a_v2 = a_v1.clone();
a_v2.push(b'!');
let blob_a1 = put_blob(&s, a_v1.clone());
let blob_a2 = put_blob(&s, a_v2);
let c1 = commit_with_named_file(&s, b"a.txt", blob_a1, vec![], "v1");
let blob_b = put_blob(&s, a_v1);
let tree2 = put(
&s,
&Object::Tree(Tree {
entries: vec![
TreeEntry {
name: b"a.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_a2,
},
TreeEntry {
name: b"b.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob_b,
},
],
}),
);
let c2 = put(
&s,
&Object::Commit(Commit::new_unannotated(
tree2,
vec![c1],
Identity::ed25519([7; 32]),
[0; 32],
b"v2".to_vec(),
2,
[0; 64],
)),
);
let bases = select_chunk_delta_bases(&s, c2, c1).unwrap();
assert_eq!(
bases.get(&blob_a2),
Some(&blob_a1),
"a.txt's edit still pairs against its own prior version"
);
assert!(
!bases.contains_key(&blob_b),
"b.txt has no old-side counterpart and must not be paired at all, \
even against a content-similar blob from a different path"
);
}
}
#[cfg(test)]
mod wire_conformance {
use super::*;
#[test]
fn packlist_wire_format_is_pinned() {
let bytes = encode_packlist(Some([0x11u8; 32]), &[[0x22u8; 32], [0x33u8; 32]]).unwrap();
let expected = "4d4b504c0101\
1111111111111111111111111111111111111111111111111111111111111111\
02\
2222222222222222222222222222222222222222222222222222222222222222\
3333333333333333333333333333333333333333333333333333333333333333";
assert_eq!(
hash::to_hex_bytes(&bytes),
expected,
"PackListNode wire format drifted (commonware-codec change?)"
);
let node = decode_packlist(&bytes).unwrap();
assert_eq!(node.prev, Some([0x11u8; 32]));
assert_eq!(node.packs, vec![[0x22u8; 32], [0x33u8; 32]]);
}
}