Skip to main content

objects/store/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Object body codecs for loose-object backends.
3
4use 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/// Store metadata needed to keep a future delta in the same bounded epoch.
20/// Losing this hint is safe: the next write falls back to a fresh HLR1 anchor.
21#[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
44/// Materialized anchor information inherited from the immediate parent.
45pub struct TreeDeltaBase<'a> {
46    pub anchor_id: ContentHash,
47    pub anchor: &'a Tree,
48    /// Delta descendants between `anchor` and the immediate parent.
49    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
69/// Encode the capture hot path. Materialized writes are cheap HLR1 anchors;
70/// eligible descendants are cumulative HDC1 deltas against the epoch anchor.
71pub fn encode_tree_hot(tree: &Tree, base: Option<TreeDeltaBase<'_>>) -> Result<EncodedTree> {
72    let hash = tree.hash();
73    // A V4 salted tree is stored as a full self-keyed HSR1 canonical body, and
74    // a tree with a Git source layout as a full HTR4 body. Either is always an
75    // anchor (never an HLR1 lean or HDC1 delta — both drop the per-entry salt
76    // and the layout), so `base` is irrelevant and it carries the `Lean`
77    // (anchor, nothing to remember) lineage kind.
78    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        // A V3 child cannot delta against a V4 salted anchor (different hash
95        // scheme, no shared preimage), nor against an anchor whose Git layout
96        // HDC1 cannot carry. Store the child as its own lean anchor rather
97        // than hard-failing the write.
98        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
154/// Heavy, seekable compression for repack/background use only.
155pub 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        // A full-tree decoder cannot represent a projection with withheld
171        // entries. The partial-store/partial-read path is
172        // [`decode_partial_tree`] + `ObjectStore::{put,read}_partial_tree`;
173        // this typed error is the backstop for callers that route an HRT1 body
174        // into the full-tree path by mistake.
175        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    // HSR1 carries its declared root inline, so it self-keys and can decode
181    // without an external key (its root is verified inside `decode_canonical`).
182    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
190/// Decode any production tree body and validate it against the external key.
191/// HDC1 callers must supply its materialized anchor; canonical and HLR1 bodies
192/// ignore `anchor`.
193pub 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        // See [`decode_tree_serialized`]: an HRT1 body carries withheld entries
209        // and is read through [`decode_partial_tree`] /
210        // `ObjectStore::read_tree`, never as a full [`Tree`].
211        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        // HTR4 (flat V3) and HSR1 (salted V4) both decode through
231        // `decode_canonical`, which dispatches on the body magic.
232        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
245/// Decode an HRT1 redacted projection body and verify it reconstructs the
246/// externally-declared tree hash `expected`.
247///
248/// This is the partial-tree counterpart to [`decode_tree_serialized_with_key`]:
249/// where that returns a full [`Tree`] and refuses an HRT1 body, this returns a
250/// verified [`PartialTree`] whose visible preimages + withheld leaf hashes
251/// reproduce `expected` (Leg 1's `reconstruct_root` contract). A partial clone
252/// verifies against the tip's declared `State.tree` through this path WITHOUT
253/// holding the withheld content.
254///
255/// [`decode_redacted_projection`] already checks that the projection's leaves
256/// reconstruct its self-declared root; the extra equality below binds that
257/// self-declared root to the externally-expected key, so a projection cannot
258/// masquerade as a different tree (mirroring the `found != expected` corruption
259/// check every full-tree decode performs).
260pub 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
269/// Return the serialized tree body stored in a loose object, decompressing
270/// only the loose-object wrapper. Migration code uses this to decode older
271/// tree schemas without teaching the current [`Tree`] reader to accept them.
272pub 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        // A V4 salted anchor cannot be a delta base for a V3 child (different
345        // hash scheme). Pre-fix this hit `encode_tree_delta` and Err'd, failing
346        // the write; it must now fall back to a lean anchor.
347        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            &current,
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            &current,
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            &current,
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, &current);
479        assert_eq!(ops.len(), 600);
480        assert!(encode_tree_delta(anchor.hash(), &anchor, &current, &ops).is_err());
481        let encoded = encode_tree_hot(
482            &current,
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, &current);
502        let delta = encode_tree_delta(anchor.hash(), &anchor, &current, &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}