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