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::{Action, ActionId, ContentHash, State, Tree},
11    store::{HeddleError, Result},
12};
13
14pub fn encode_blob_content(content: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
15    Ok(compress(content, config)?.unwrap_or_else(|| content.to_vec()))
16}
17
18pub fn decode_blob_content(data: &[u8]) -> Result<Vec<u8>> {
19    if is_compressed(data) {
20        Ok(decompress(data)?)
21    } else {
22        Ok(data.to_vec())
23    }
24}
25
26pub fn encode_tree(tree: &Tree, _config: &CompressionConfig) -> Result<(ContentHash, Vec<u8>)> {
27    // Canonical trees stay uncompressed so a resume cursor can seek to an
28    // entry frame without decompressing the prefix.
29    Ok((tree.hash(), tree.encode_canonical()?))
30}
31
32pub fn decode_tree(data: &[u8]) -> Result<Tree> {
33    let decoded = decode_tree_body(data)?;
34    decode_tree_serialized(&decoded)
35}
36
37pub fn decode_tree_serialized(data: &[u8]) -> Result<Tree> {
38    Tree::decode_canonical(data).map_err(HeddleError::from)
39}
40
41/// Return the serialized tree body stored in a loose object, decompressing
42/// only the loose-object wrapper. Migration code uses this to decode older
43/// tree schemas without teaching the current [`Tree`] reader to accept them.
44pub fn decode_tree_body(data: &[u8]) -> Result<Vec<u8>> {
45    Ok(decompress_with_dictionary(data)?)
46}
47
48pub fn encode_state(state: &State, config: &CompressionConfig) -> Result<Vec<u8>> {
49    let serialized = rmp_serde::to_vec(state)?;
50    Ok(
51        compress_with_dictionary(&serialized, config, CompressionDictionary::TreeStateV1)?
52            .unwrap_or(serialized),
53    )
54}
55
56pub fn decode_state(data: &[u8]) -> Result<State> {
57    let decoded = decompress_with_dictionary(data)?;
58    let mut state: State = rmp_serde::from_slice(&decoded)?;
59    state.state_id = state.id();
60    Ok(state)
61}
62
63pub fn encode_action(
64    action: &mut Action,
65    config: &CompressionConfig,
66) -> Result<(ActionId, Vec<u8>)> {
67    let id = action.id();
68    let serialized = rmp_serde::to_vec(action)?;
69    let data = compress(&serialized, config)?.unwrap_or(serialized);
70    Ok((id, data))
71}
72
73pub fn decode_action(data: &[u8]) -> Result<Action> {
74    let decoded = decode_body(data)?;
75    Ok(rmp_serde::from_slice(&decoded)?)
76}
77
78fn decode_body(data: &[u8]) -> Result<Vec<u8>> {
79    if is_compressed(data) {
80        Ok(decompress(data)?)
81    } else {
82        Ok(data.to_vec())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::object::{Attribution, Operation, Principal, StateId, TreeEntry};
90
91    #[test]
92    fn encode_decode_blob_content_matches_old_recipe() {
93        let content = b"codec blob content ".repeat(64);
94        for config in compression_configs() {
95            let expected = old_encode_raw(&content, &config).unwrap();
96            let encoded = encode_blob_content(&content, &config).unwrap();
97            assert_eq!(encoded, expected);
98            assert_eq!(decode_blob_content(&encoded).unwrap(), content);
99        }
100    }
101
102    #[test]
103    fn encode_decode_tree() {
104        let blob_hash = ContentHash::compute(b"codec-tree-blob");
105        let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
106        for config in compression_configs() {
107            let (hash, encoded) = encode_tree(&tree, &config).unwrap();
108            assert_eq!(hash, tree.hash());
109            assert_eq!(decode_tree(&encoded).unwrap(), tree);
110        }
111    }
112
113    #[test]
114    #[cfg(feature = "zstd")]
115    fn tree_and_state_use_versioned_dictionary_frames() {
116        let tree = Tree::from_entries(
117            (0..24)
118                .map(|index| {
119                    TreeEntry::file(
120                        format!("module_{index:02}.rs"),
121                        ContentHash::compute(format!("blob-{index}").as_bytes()),
122                        false,
123                    )
124                    .unwrap()
125                })
126                .collect(),
127        );
128        let state = State::new(
129            tree.hash(),
130            vec![StateId::from_bytes([7; 32])],
131            sample_attribution(),
132        )
133        .with_intent("dictionary frame verification ".repeat(32));
134
135        let (_, encoded_tree) = encode_tree(&tree, &CompressionConfig::default()).unwrap();
136        let encoded_state = encode_state(&state, &CompressionConfig::default()).unwrap();
137
138        assert!(
139            crate::object::is_canonical_tree(&encoded_tree),
140            "trees stay uncompressed HTR4 so resume can seek"
141        );
142        assert_eq!(&encoded_state[9..13], &1_u32.to_be_bytes());
143    }
144
145    #[test]
146    #[cfg(feature = "zstd")]
147    fn tree_state_dictionary_corpus_roundtrips_byte_identically() {
148        let config = CompressionConfig::default();
149
150        for revision in 0..64 {
151            let tree = Tree::from_entries(
152                (0..32)
153                    .map(|entry| {
154                        TreeEntry::file(
155                            format!("module_{entry:02}.rs"),
156                            ContentHash::compute(
157                                format!("revision-{revision}-blob-{entry}").as_bytes(),
158                            ),
159                            entry % 11 == 0,
160                        )
161                        .unwrap()
162                    })
163                    .collect(),
164            );
165            let serialized_tree = tree.encode_canonical().unwrap();
166            let (_, encoded_tree) = encode_tree(&tree, &config).unwrap();
167            assert_eq!(decode_tree_body(&encoded_tree).unwrap(), serialized_tree);
168
169            let state = State::new(
170                tree.hash(),
171                vec![StateId::from_bytes([revision; 32])],
172                sample_attribution(),
173            )
174            .with_intent(format!(
175                "Update the representative tree/state corpus at revision {revision}. {}",
176                "Preserve byte-identical object bodies. ".repeat(12)
177            ));
178            let serialized_state = rmp_serde::to_vec(&state).unwrap();
179            let encoded_state = encode_state(&state, &config).unwrap();
180            assert_eq!(
181                decompress_with_dictionary(&encoded_state).unwrap(),
182                serialized_state
183            );
184        }
185    }
186
187    #[test]
188    fn encode_decode_state() {
189        let attribution = sample_attribution();
190        let state = State::new(ContentHash::compute(b"codec-tree"), vec![], attribution)
191            .with_intent("codec state");
192        for config in compression_configs() {
193            let encoded = encode_state(&state, &config).unwrap();
194            assert_eq!(decode_state(&encoded).unwrap(), state);
195        }
196    }
197
198    #[test]
199    fn encode_decode_action_matches_old_recipe() {
200        let attribution = sample_attribution();
201        for config in compression_configs() {
202            let mut action = Action::new(
203                None,
204                StateId::from_bytes([1; 32]),
205                Operation::Snapshot,
206                "codec action",
207                attribution.clone(),
208            );
209            let id = action.id();
210            let serialized = rmp_serde::to_vec(&action).unwrap();
211            let expected = old_encode_raw(&serialized, &config).unwrap();
212
213            let (encoded_id, encoded) = encode_action(&mut action, &config).unwrap();
214            assert_eq!(encoded_id, id);
215            assert_eq!(encoded, expected);
216
217            let decoded = decode_action(&encoded).unwrap();
218            assert_eq!(decoded.compute_id(), id);
219            assert_eq!(decoded.from_state, action.from_state);
220            assert_eq!(decoded.to_state, action.to_state);
221            assert_eq!(decoded.operation, action.operation);
222            assert_eq!(decoded.description, action.description);
223            assert_eq!(decoded.semantic_changes, action.semantic_changes);
224            assert_eq!(decoded.attribution, action.attribution);
225            assert_eq!(decoded.timestamp, action.timestamp);
226        }
227    }
228
229    fn old_encode_raw(data: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
230        Ok(compress(data, config)?.unwrap_or_else(|| data.to_vec()))
231    }
232
233    fn compression_configs() -> Vec<CompressionConfig> {
234        #[cfg(feature = "zstd")]
235        {
236            vec![
237                CompressionConfig::default(),
238                CompressionConfig::disabled(),
239                CompressionConfig {
240                    enabled: true,
241                    level: 9,
242                    min_size: 0,
243                    max_delta_size: CompressionConfig::default().max_delta_size,
244                },
245            ]
246        }
247        #[cfg(not(feature = "zstd"))]
248        {
249            vec![CompressionConfig::default(), CompressionConfig::disabled()]
250        }
251    }
252
253    fn sample_attribution() -> Attribution {
254        Attribution::human(Principal::new("Codec Test", "codec@example.com"))
255    }
256}