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