1use heddle_format::compression::{
5 CompressionConfig, CompressionDictionary, compress, compress_with_dictionary, decompress,
6 decompress_with_dictionary, is_compressed,
7};
8
9use crate::{
10 object::{
11 Action, ActionId, ContentHash, PartialTree, State, TREE_DELTA_ANCHOR_INTERVAL,
12 TREE_DELTA_MAX_OPS, Tree, decode_redacted_projection, decode_tree_delta,
13 decode_tree_delta_header, encode_tree_delta, is_canonical_tree, is_delta_tree,
14 is_lean_tree, is_redacted_tree, is_salted_tree, tree_delta,
15 },
16 store::{HeddleError, Result},
17};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct TreeLineage {
23 pub anchor: ContentHash,
24 pub depth: u8,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum TreeEncodingKind {
29 Lean,
30 Delta {
31 anchor: ContentHash,
32 depth: u8,
33 op_count: usize,
34 },
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct EncodedTree {
39 pub hash: ContentHash,
40 pub data: Vec<u8>,
41 pub kind: TreeEncodingKind,
42}
43
44pub struct TreeDeltaBase<'a> {
46 pub anchor_id: ContentHash,
47 pub anchor: &'a Tree,
48 pub parent_depth: u8,
50}
51
52pub fn encode_blob_content(content: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
53 Ok(compress(content, config)?.unwrap_or_else(|| content.to_vec()))
54}
55
56pub fn decode_blob_content(data: &[u8]) -> Result<Vec<u8>> {
57 if is_compressed(data) {
58 Ok(decompress(data)?)
59 } else {
60 Ok(data.to_vec())
61 }
62}
63
64pub fn encode_tree(tree: &Tree, _config: &CompressionConfig) -> Result<(ContentHash, Vec<u8>)> {
65 let encoded = encode_tree_hot(tree, None)?;
66 Ok((encoded.hash, encoded.data))
67}
68
69pub fn encode_tree_hot(tree: &Tree, base: Option<TreeDeltaBase<'_>>) -> Result<EncodedTree> {
72 let hash = tree.hash();
73 if tree.requires_canonical_body() {
79 return Ok(EncodedTree {
80 hash,
81 data: tree.encode_canonical()?,
82 kind: TreeEncodingKind::Lean,
83 });
84 }
85 let lean = tree.encode_lean()?;
86 let Some(base) = base else {
87 return Ok(EncodedTree {
88 hash,
89 data: lean,
90 kind: TreeEncodingKind::Lean,
91 });
92 };
93 if base.anchor.requires_canonical_body() {
94 return Ok(EncodedTree {
99 hash,
100 data: lean,
101 kind: TreeEncodingKind::Lean,
102 });
103 }
104 if hash == base.anchor_id {
105 return Ok(EncodedTree {
106 hash,
107 data: lean,
108 kind: TreeEncodingKind::Lean,
109 });
110 }
111 let Some(depth) = base.parent_depth.checked_add(1) else {
112 return Ok(EncodedTree {
113 hash,
114 data: lean,
115 kind: TreeEncodingKind::Lean,
116 });
117 };
118 if depth >= TREE_DELTA_ANCHOR_INTERVAL {
119 return Ok(EncodedTree {
120 hash,
121 data: lean,
122 kind: TreeEncodingKind::Lean,
123 });
124 }
125 let ops = tree_delta(base.anchor, tree);
126 if ops.len() > TREE_DELTA_MAX_OPS {
127 return Ok(EncodedTree {
128 hash,
129 data: lean,
130 kind: TreeEncodingKind::Lean,
131 });
132 }
133 let delta = encode_tree_delta(base.anchor_id, base.anchor, tree, &ops)?;
134 let header = decode_tree_delta_header(&delta)?;
135 let porch_is_bounded = header.first_base_count <= 1 && header.hundred_base_count <= 100;
136 if !porch_is_bounded || delta.len() >= lean.len() {
137 return Ok(EncodedTree {
138 hash,
139 data: lean,
140 kind: TreeEncodingKind::Lean,
141 });
142 }
143 Ok(EncodedTree {
144 hash,
145 data: delta,
146 kind: TreeEncodingKind::Delta {
147 anchor: base.anchor_id,
148 depth,
149 op_count: ops.len(),
150 },
151 })
152}
153
154pub fn encode_tree_at_rest(tree: &Tree, config: &CompressionConfig) -> Result<Vec<u8>> {
156 if config.enabled && tree.len() >= crate::object::TREE_BLOCK_MIN_ENTRIES {
157 Ok(tree.encode_canonical_blocked(config.level, config.min_size)?)
158 } else {
159 Ok(tree.encode_canonical()?)
160 }
161}
162
163pub fn decode_tree(data: &[u8]) -> Result<Tree> {
164 let decoded = decode_tree_body(data)?;
165 decode_tree_serialized(&decoded)
166}
167
168pub fn decode_tree_serialized(data: &[u8]) -> Result<Tree> {
169 if is_redacted_tree(data) {
170 return Err(HeddleError::RedactedTree(
176 "HRT1 redacted projection must be read via decode_partial_tree, not as a full tree"
177 .to_string(),
178 ));
179 }
180 if !is_canonical_tree(data) && !is_salted_tree(data) {
183 return Err(HeddleError::InvalidObject(
184 "HLR1/HDC1 tree decoding requires the external object key".to_string(),
185 ));
186 }
187 Tree::decode_canonical(data).map_err(HeddleError::from)
188}
189
190pub fn decode_tree_with_key(
194 data: &[u8],
195 expected: ContentHash,
196 anchor: Option<&Tree>,
197) -> Result<Tree> {
198 let decoded = decode_tree_body(data)?;
199 decode_tree_serialized_with_key(&decoded, expected, anchor)
200}
201
202pub fn decode_tree_serialized_with_key(
203 data: &[u8],
204 expected: ContentHash,
205 anchor: Option<&Tree>,
206) -> Result<Tree> {
207 if is_redacted_tree(data) {
208 return Err(HeddleError::RedactedTree(
212 "HRT1 redacted projection must be read via decode_partial_tree, not as a full tree"
213 .to_string(),
214 ));
215 }
216 let tree = if is_lean_tree(data) {
217 Tree::decode_lean(data, expected)?
218 } else if is_delta_tree(data) {
219 let header = decode_tree_delta_header(data)?;
220 if header.anchor == expected {
221 return Err(HeddleError::InvalidObject(
222 "HDC1 result id must differ from its anchor id".to_string(),
223 ));
224 }
225 let anchor = anchor.ok_or_else(|| {
226 HeddleError::InvalidObject("HDC1 tree is missing its materialized anchor".to_string())
227 })?;
228 decode_tree_delta(data, anchor, expected)?
229 } else if is_canonical_tree(data) || is_salted_tree(data) {
230 Tree::decode_canonical(data)?
233 } else {
234 return Err(HeddleError::InvalidObject(
235 "unsupported tree storage body".to_string(),
236 ));
237 };
238 let found = tree.hash();
239 if found != expected {
240 return Err(HeddleError::Corruption { expected, found });
241 }
242 Ok(tree)
243}
244
245pub fn decode_partial_tree(data: &[u8], expected: ContentHash) -> Result<PartialTree> {
261 let partial = decode_redacted_projection(data)?;
262 let found = partial.declared_root();
263 if found != expected {
264 return Err(HeddleError::Corruption { expected, found });
265 }
266 Ok(partial)
267}
268
269pub fn decode_tree_body(data: &[u8]) -> Result<Vec<u8>> {
273 Ok(decompress_with_dictionary(data)?)
274}
275
276pub fn encode_state(state: &State, config: &CompressionConfig) -> Result<Vec<u8>> {
277 let serialized = rmp_serde::to_vec(state)?;
278 Ok(
279 compress_with_dictionary(&serialized, config, CompressionDictionary::TreeStateV1)?
280 .unwrap_or(serialized),
281 )
282}
283
284pub fn decode_state(data: &[u8]) -> Result<State> {
285 let decoded = decompress_with_dictionary(data)?;
286 let mut state: State = rmp_serde::from_slice(&decoded)?;
287 state.state_id = state.id();
288 Ok(state)
289}
290
291pub fn encode_action(
292 action: &mut Action,
293 config: &CompressionConfig,
294) -> Result<(ActionId, Vec<u8>)> {
295 let id = action.id();
296 let serialized = rmp_serde::to_vec(action)?;
297 let data = compress(&serialized, config)?.unwrap_or(serialized);
298 Ok((id, data))
299}
300
301pub fn decode_action(data: &[u8]) -> Result<Action> {
302 let decoded = decode_body(data)?;
303 Ok(rmp_serde::from_slice(&decoded)?)
304}
305
306fn decode_body(data: &[u8]) -> Result<Vec<u8>> {
307 if is_compressed(data) {
308 Ok(decompress(data)?)
309 } else {
310 Ok(data.to_vec())
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::object::{Attribution, Operation, Principal, StateId, TreeEntry};
318
319 #[test]
320 fn encode_decode_blob_content_matches_old_recipe() {
321 let content = b"codec blob content ".repeat(64);
322 for config in compression_configs() {
323 let expected = old_encode_raw(&content, &config).unwrap();
324 let encoded = encode_blob_content(&content, &config).unwrap();
325 assert_eq!(encoded, expected);
326 assert_eq!(decode_blob_content(&encoded).unwrap(), content);
327 }
328 }
329
330 #[test]
331 fn encode_decode_tree() {
332 let blob_hash = ContentHash::compute(b"codec-tree-blob");
333 let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
334 for config in compression_configs() {
335 let (hash, encoded) = encode_tree(&tree, &config).unwrap();
336 assert_eq!(hash, tree.hash());
337 assert!(crate::object::is_lean_tree(&encoded));
338 assert_eq!(decode_tree_with_key(&encoded, hash, None).unwrap(), tree);
339 }
340 }
341
342 #[test]
343 fn v3_child_over_a_v4_anchor_falls_back_to_lean_not_error() {
344 let v4_anchor = Tree::from_entries_salted_v4(
348 vec![
349 TreeEntry::file("a", ContentHash::compute(b"a"), false).unwrap(),
350 TreeEntry::file("b", ContentHash::compute(b"b"), false).unwrap(),
351 ],
352 vec![[0x11; 32], [0x22; 32]],
353 )
354 .unwrap();
355 let v3_child = Tree::from_entries(vec![
356 TreeEntry::file("a", ContentHash::compute(b"a"), false).unwrap(),
357 ]);
358 let encoded = encode_tree_hot(
359 &v3_child,
360 Some(TreeDeltaBase {
361 anchor_id: v4_anchor.hash(),
362 anchor: &v4_anchor,
363 parent_depth: 0,
364 }),
365 )
366 .expect("v3-over-v4 must not error");
367 assert_eq!(encoded.kind, TreeEncodingKind::Lean);
368 assert!(crate::object::is_lean_tree(&encoded.data));
369 assert_eq!(encoded.hash, v3_child.hash());
370 assert_eq!(
371 decode_tree_with_key(&encoded.data, v3_child.hash(), None).unwrap(),
372 v3_child
373 );
374 }
375
376 #[test]
377 fn lean_and_delta_round_trip_against_external_keys() {
378 let anchor = tree_fixture(240, None);
379 let current = tree_fixture(240, Some((117, b"changed")));
380 let lean = encode_tree_hot(&anchor, None).unwrap();
381 assert_eq!(lean.kind, TreeEncodingKind::Lean);
382 assert_eq!(
383 decode_tree_with_key(&lean.data, anchor.hash(), None).unwrap(),
384 anchor
385 );
386
387 let delta = encode_tree_hot(
388 ¤t,
389 Some(TreeDeltaBase {
390 anchor_id: anchor.hash(),
391 anchor: &anchor,
392 parent_depth: 0,
393 }),
394 )
395 .unwrap();
396 assert!(matches!(
397 delta.kind,
398 TreeEncodingKind::Delta {
399 anchor: _,
400 depth: 1,
401 op_count: 1
402 }
403 ));
404 assert!(crate::object::is_delta_tree(&delta.data));
405 assert_eq!(
406 decode_tree_with_key(&delta.data, current.hash(), Some(&anchor)).unwrap(),
407 current
408 );
409 }
410
411 #[test]
412 fn result_equal_to_anchor_is_materialized_instead_of_delta_encoded() {
413 let anchor = tree_fixture(240, None);
414 let encoded = encode_tree_hot(
415 &anchor,
416 Some(TreeDeltaBase {
417 anchor_id: anchor.hash(),
418 anchor: &anchor,
419 parent_depth: 1,
420 }),
421 )
422 .unwrap();
423
424 assert_eq!(encoded.kind, TreeEncodingKind::Lean);
425 assert!(crate::object::is_lean_tree(&encoded.data));
426 }
427
428 #[test]
429 fn delta_refreshes_anchor_after_127_descendants() {
430 let anchor = tree_fixture(240, None);
431 let current = tree_fixture(240, Some((117, b"changed")));
432
433 let last_descendant = encode_tree_hot(
434 ¤t,
435 Some(TreeDeltaBase {
436 anchor_id: anchor.hash(),
437 anchor: &anchor,
438 parent_depth: TREE_DELTA_ANCHOR_INTERVAL - 2,
439 }),
440 )
441 .unwrap();
442 assert!(matches!(
443 last_descendant.kind,
444 TreeEncodingKind::Delta { depth: 127, .. }
445 ));
446
447 let refreshed = encode_tree_hot(
448 ¤t,
449 Some(TreeDeltaBase {
450 anchor_id: anchor.hash(),
451 anchor: &anchor,
452 parent_depth: TREE_DELTA_ANCHOR_INTERVAL - 1,
453 }),
454 )
455 .unwrap();
456 assert_eq!(refreshed.kind, TreeEncodingKind::Lean);
457 assert!(crate::object::is_lean_tree(&refreshed.data));
458 }
459
460 #[test]
461 fn delta_over_512_operations_refreshes_the_anchor() {
462 let anchor = tree_fixture(600, None);
463 let current = Tree::from_entries(
464 anchor
465 .entries()
466 .iter()
467 .enumerate()
468 .map(|(index, entry)| {
469 TreeEntry::file(
470 entry.name(),
471 ContentHash::compute(format!("changed-{index}").as_bytes()),
472 false,
473 )
474 .unwrap()
475 })
476 .collect(),
477 );
478 let ops = tree_delta(&anchor, ¤t);
479 assert_eq!(ops.len(), 600);
480 assert!(encode_tree_delta(anchor.hash(), &anchor, ¤t, &ops).is_err());
481 let encoded = encode_tree_hot(
482 ¤t,
483 Some(TreeDeltaBase {
484 anchor_id: anchor.hash(),
485 anchor: &anchor,
486 parent_depth: 0,
487 }),
488 )
489 .unwrap();
490 assert_eq!(encoded.kind, TreeEncodingKind::Lean);
491 }
492
493 #[test]
494 fn every_tree_form_validates_the_external_key() {
495 let anchor = tree_fixture(240, None);
496 let current = tree_fixture(240, Some((117, b"changed")));
497 let wrong = ContentHash::compute(b"wrong-tree-key");
498 let lean = anchor.encode_lean().unwrap();
499 assert!(decode_tree_with_key(&lean, wrong, None).is_err());
500
501 let ops = tree_delta(&anchor, ¤t);
502 let delta = encode_tree_delta(anchor.hash(), &anchor, ¤t, &ops).unwrap();
503 assert!(decode_tree_with_key(&delta, wrong, Some(&anchor)).is_err());
504
505 let raw = current.encode_canonical().unwrap();
506 assert!(decode_tree_with_key(&raw, wrong, None).is_err());
507 }
508
509 #[test]
510 #[cfg(feature = "zstd")]
511 fn tree_and_state_use_versioned_dictionary_frames() {
512 let tree = Tree::from_entries(
513 (0..24)
514 .map(|index| {
515 TreeEntry::file(
516 format!("module_{index:02}.rs"),
517 ContentHash::compute(format!("blob-{index}").as_bytes()),
518 false,
519 )
520 .unwrap()
521 })
522 .collect(),
523 );
524 let state = State::new(
525 tree.hash(),
526 vec![StateId::from_bytes([7; 32])],
527 sample_attribution(),
528 )
529 .with_intent("dictionary frame verification ".repeat(32));
530
531 let encoded_tree = encode_tree_at_rest(&tree, &CompressionConfig::default()).unwrap();
532 let encoded_state = encode_state(&state, &CompressionConfig::default()).unwrap();
533
534 assert!(
535 crate::object::is_canonical_tree(&encoded_tree),
536 "at-rest trees remain versioned HTR4 so resume can seek"
537 );
538 assert_eq!(&encoded_state[9..13], &1_u32.to_be_bytes());
539 }
540
541 #[test]
542 #[cfg(feature = "zstd")]
543 fn store_decoder_reads_raw_v4_and_blocked_v5() {
544 let tree = tree_fixture(600, None);
545 let raw = tree.encode_canonical().unwrap();
546 let blocked = encode_tree_at_rest(
547 &tree,
548 &CompressionConfig {
549 enabled: true,
550 level: 3,
551 min_size: 0,
552 max_delta_size: CompressionConfig::default().max_delta_size,
553 },
554 )
555 .unwrap();
556 assert_eq!(raw[4], crate::object::TREE_ENCODING_VERSION);
557 assert_eq!(blocked[4], crate::object::TREE_BLOCK_ENCODING_VERSION);
558 assert_eq!(decode_tree_with_key(&raw, tree.hash(), None).unwrap(), tree);
559 assert_eq!(
560 decode_tree_with_key(&blocked, tree.hash(), None).unwrap(),
561 tree
562 );
563 }
564
565 #[test]
566 #[cfg(feature = "zstd")]
567 fn tree_state_dictionary_corpus_roundtrips_byte_identically() {
568 let config = CompressionConfig::default();
569
570 for revision in 0..64 {
571 let tree = Tree::from_entries(
572 (0..32)
573 .map(|entry| {
574 TreeEntry::file(
575 format!("module_{entry:02}.rs"),
576 ContentHash::compute(
577 format!("revision-{revision}-blob-{entry}").as_bytes(),
578 ),
579 entry % 11 == 0,
580 )
581 .unwrap()
582 })
583 .collect(),
584 );
585 let encoded_tree = encode_tree_at_rest(&tree, &config).unwrap();
586 assert_eq!(Tree::decode_canonical(&encoded_tree).unwrap(), tree);
587
588 let state = State::new(
589 tree.hash(),
590 vec![StateId::from_bytes([revision; 32])],
591 sample_attribution(),
592 )
593 .with_intent(format!(
594 "Update the representative tree/state corpus at revision {revision}. {}",
595 "Preserve byte-identical object bodies. ".repeat(12)
596 ));
597 let serialized_state = rmp_serde::to_vec(&state).unwrap();
598 let encoded_state = encode_state(&state, &config).unwrap();
599 assert_eq!(
600 decompress_with_dictionary(&encoded_state).unwrap(),
601 serialized_state
602 );
603 }
604 }
605
606 #[test]
607 fn encode_decode_state() {
608 let attribution = sample_attribution();
609 let state = State::new(ContentHash::compute(b"codec-tree"), vec![], attribution)
610 .with_intent("codec state");
611 for config in compression_configs() {
612 let encoded = encode_state(&state, &config).unwrap();
613 assert_eq!(decode_state(&encoded).unwrap(), state);
614 }
615 }
616
617 #[test]
618 fn encode_decode_action_matches_old_recipe() {
619 let attribution = sample_attribution();
620 for config in compression_configs() {
621 let mut action = Action::new(
622 None,
623 StateId::from_bytes([1; 32]),
624 Operation::Snapshot,
625 "codec action",
626 attribution.clone(),
627 );
628 let id = action.id();
629 let serialized = rmp_serde::to_vec(&action).unwrap();
630 let expected = old_encode_raw(&serialized, &config).unwrap();
631
632 let (encoded_id, encoded) = encode_action(&mut action, &config).unwrap();
633 assert_eq!(encoded_id, id);
634 assert_eq!(encoded, expected);
635
636 let decoded = decode_action(&encoded).unwrap();
637 assert_eq!(decoded.compute_id(), id);
638 assert_eq!(decoded.from_state, action.from_state);
639 assert_eq!(decoded.to_state, action.to_state);
640 assert_eq!(decoded.operation, action.operation);
641 assert_eq!(decoded.description, action.description);
642 assert_eq!(decoded.semantic_changes, action.semantic_changes);
643 assert_eq!(decoded.attribution, action.attribution);
644 assert_eq!(decoded.timestamp, action.timestamp);
645 }
646 }
647
648 fn old_encode_raw(data: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
649 Ok(compress(data, config)?.unwrap_or_else(|| data.to_vec()))
650 }
651
652 fn tree_fixture(entries: usize, changed: Option<(usize, &[u8])>) -> Tree {
653 Tree::from_entries(
654 (0..entries)
655 .map(|index| {
656 let payload = changed
657 .filter(|(changed_index, _)| *changed_index == index)
658 .map_or_else(
659 || format!("blob-{index}").into_bytes(),
660 |(_, payload)| payload.to_vec(),
661 );
662 TreeEntry::file(
663 format!("module_{index:04}.rs"),
664 ContentHash::compute(&payload),
665 false,
666 )
667 .unwrap()
668 })
669 .collect(),
670 )
671 }
672
673 fn compression_configs() -> Vec<CompressionConfig> {
674 #[cfg(feature = "zstd")]
675 {
676 vec![
677 CompressionConfig::default(),
678 CompressionConfig::disabled(),
679 CompressionConfig {
680 enabled: true,
681 level: 9,
682 min_size: 0,
683 max_delta_size: CompressionConfig::default().max_delta_size,
684 },
685 ]
686 }
687 #[cfg(not(feature = "zstd"))]
688 {
689 vec![CompressionConfig::default(), CompressionConfig::disabled()]
690 }
691 }
692
693 fn sample_attribution() -> Attribution {
694 Attribution::human(Principal::new("Codec Test", "codec@example.com"))
695 }
696}