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, 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/// 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. It
74    // is always an anchor (never an HLR1 lean or HDC1 delta — both drop the
75    // per-entry salt), so `base` is irrelevant and it carries the `Lean`
76    // (anchor, nothing to remember) lineage kind.
77    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        // A V3 child cannot delta against a V4 salted anchor (different hash
94        // scheme, no shared preimage). Store the child as its own lean anchor
95        // rather than hard-failing the write.
96        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
152/// Heavy, seekable compression for repack/background use only.
153pub 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        // A full-tree decoder cannot represent a projection with withheld
169        // entries. The partial-store/partial-read path is
170        // [`decode_partial_tree`] + `ObjectStore::{put,read}_partial_tree`;
171        // this typed error is the backstop for callers that route an HRT1 body
172        // into the full-tree path by mistake.
173        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    // HSR1 carries its declared root inline, so it self-keys and can decode
179    // without an external key (its root is verified inside `decode_canonical`).
180    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
188/// Decode any production tree body and validate it against the external key.
189/// HDC1 callers must supply its materialized anchor; canonical and HLR1 bodies
190/// ignore `anchor`.
191pub 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        // See [`decode_tree_serialized`]: an HRT1 body carries withheld entries
207        // and is read through [`decode_partial_tree`] /
208        // `ObjectStore::read_tree`, never as a full [`Tree`].
209        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        // HTR4 (flat V3) and HSR1 (salted V4) both decode through
229        // `decode_canonical`, which dispatches on the body magic.
230        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
243/// Decode an HRT1 redacted projection body and verify it reconstructs the
244/// externally-declared tree hash `expected`.
245///
246/// This is the partial-tree counterpart to [`decode_tree_serialized_with_key`]:
247/// where that returns a full [`Tree`] and refuses an HRT1 body, this returns a
248/// verified [`PartialTree`] whose visible preimages + withheld leaf hashes
249/// reproduce `expected` (Leg 1's `reconstruct_root` contract). A partial clone
250/// verifies against the tip's declared `State.tree` through this path WITHOUT
251/// holding the withheld content.
252///
253/// [`decode_redacted_projection`] already checks that the projection's leaves
254/// reconstruct its self-declared root; the extra equality below binds that
255/// self-declared root to the externally-expected key, so a projection cannot
256/// masquerade as a different tree (mirroring the `found != expected` corruption
257/// check every full-tree decode performs).
258pub 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
267/// Return the serialized tree body stored in a loose object, decompressing
268/// only the loose-object wrapper. Migration code uses this to decode older
269/// tree schemas without teaching the current [`Tree`] reader to accept them.
270pub 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        // A V4 salted anchor cannot be a delta base for a V3 child (different
343        // hash scheme). Pre-fix this hit `encode_tree_delta` and Err'd, failing
344        // the write; it must now fall back to a lean anchor.
345        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            &current,
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            &current,
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            &current,
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, &current);
477        assert_eq!(ops.len(), 600);
478        assert!(encode_tree_delta(anchor.hash(), &anchor, &current, &ops).is_err());
479        let encoded = encode_tree_hot(
480            &current,
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, &current);
500        let delta = encode_tree_delta(anchor.hash(), &anchor, &current, &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}