Skip to main content

corium_store/
snapshot.rs

1//! Chunked index-snapshot encoding.
2//!
3//! A published covering index is a **manifest** blob naming a sequence of
4//! **leaf chunk** blobs; concatenating the chunks in manifest order yields
5//! the index's sorted, length-prefixed key stream. Chunk boundaries are
6//! content-defined (a function of each boundary key alone), so an unchanged
7//! run of keys always produces byte-identical chunks: consecutive
8//! publications share every untouched chunk by content id, and only the
9//! chunks a change lands in are re-uploaded. Snapshots published before
10//! format 3 stored the whole key stream as one flat blob; readers accept
11//! both by sniffing the manifest magic.
12
13use corium_core::chunk;
14
15use crate::{BlobId, StoreError};
16
17/// First line of every index-manifest blob. A flat key-stream blob can
18/// never start with these bytes: its first eight bytes are a big-endian key
19/// length, and this text decodes to an impossible length.
20pub const INDEX_MANIFEST_MAGIC: &str = "corium-index-manifest-v1";
21
22/// Reports whether blob bytes are an index manifest (vs a flat key stream).
23#[must_use]
24pub fn is_index_manifest(bytes: &[u8]) -> bool {
25    bytes.starts_with(INDEX_MANIFEST_MAGIC.as_bytes())
26}
27
28/// Encodes a manifest naming `children` leaf chunks in key order.
29#[must_use]
30pub fn encode_index_manifest(children: &[BlobId]) -> Vec<u8> {
31    let mut out = String::from(INDEX_MANIFEST_MAGIC);
32    out.push('\n');
33    for child in children {
34        out.push_str(child.as_str());
35        out.push('\n');
36    }
37    out.into_bytes()
38}
39
40/// Decodes a manifest's leaf-chunk ids in key order.
41///
42/// # Errors
43/// Returns an error when the bytes are not a manifest or a child id line is
44/// malformed.
45pub fn decode_index_manifest(bytes: &[u8]) -> Result<Vec<BlobId>, StoreError> {
46    let corrupt = |detail: &str| {
47        StoreError::Io(std::io::Error::other(format!(
48            "malformed index manifest: {detail}"
49        )))
50    };
51    let text = std::str::from_utf8(bytes).map_err(|_| corrupt("not UTF-8"))?;
52    let mut lines = text.lines();
53    if lines.next() != Some(INDEX_MANIFEST_MAGIC) {
54        return Err(corrupt("missing magic"));
55    }
56    lines
57        .map(|line| BlobId::from_hex(line).ok_or_else(|| corrupt("invalid child id")))
58        .collect()
59}
60
61/// Returns the blob ids referenced by an index blob: a manifest's children,
62/// or nothing for a flat (pre-format-3) key stream. Garbage collection and
63/// backup share this walk so reachability can never diverge between them.
64///
65/// # Errors
66/// Returns an error for a malformed manifest.
67pub fn index_blob_children(bytes: &[u8]) -> Result<Vec<BlobId>, StoreError> {
68    if is_index_manifest(bytes) {
69        decode_index_manifest(bytes)
70    } else {
71        Ok(Vec::new())
72    }
73}
74
75/// Decodes one `(u64 big-endian length, key)*` chunk or flat segment blob
76/// back into its sorted key stream. Inverse of [`chunk_segment_keys`]'
77/// per-chunk framing; a pre-format-3 flat snapshot is one such run.
78///
79/// # Errors
80/// Returns an error when the framing is truncated or a length is unrepresentable.
81pub fn decode_segment_keys(bytes: &[u8]) -> Result<Vec<Vec<u8>>, StoreError> {
82    let truncated = || StoreError::Io(std::io::Error::other("truncated segment"));
83    let mut keys = Vec::new();
84    let mut input = bytes;
85    while !input.is_empty() {
86        let (len_bytes, rest) = input.split_at_checked(8).ok_or_else(truncated)?;
87        let len = usize::try_from(u64::from_be_bytes(len_bytes.try_into().unwrap_or_default()))
88            .map_err(|_| StoreError::Io(std::io::Error::other("segment key too large")))?;
89        let (key, rest) = rest.split_at_checked(len).ok_or_else(truncated)?;
90        keys.push(key.to_vec());
91        input = rest;
92    }
93    Ok(keys)
94}
95
96/// Encodes one chunk's worth of keys: a run of `(u64 big-endian length,
97/// key)` records — the same framing as a whole pre-format-3 segment, so one
98/// decoder reads both.
99///
100/// Callers that already hold a chunk's keys (an index segment's leaf, whose
101/// boundaries are the ones [`chunk_segment_keys`] cuts at) encode it
102/// directly instead of re-chunking the stream around it.
103#[must_use]
104pub fn encode_segment_chunk<'a>(keys: impl IntoIterator<Item = &'a [u8]>) -> Vec<u8> {
105    let mut out = Vec::new();
106    for key in keys {
107        out.extend_from_slice(&(key.len() as u64).to_be_bytes());
108        out.extend_from_slice(key);
109    }
110    out
111}
112
113/// Splits a sorted key stream into encoded leaf chunks.
114///
115/// Boundaries come from [`corium_core::chunk`], the one rule that also
116/// decides where an in-memory index segment cuts its leaves — so a segment's
117/// leaf is exactly one chunk of this stream, and an indexing pass re-encodes
118/// only the leaves it rebuilt.
119#[must_use]
120pub fn chunk_segment_keys<'a>(keys: impl IntoIterator<Item = &'a [u8]>) -> Vec<Vec<u8>> {
121    let mut chunks = Vec::new();
122    let mut chunk = Vec::new();
123    let mut entries = 0usize;
124    for key in keys {
125        chunk.extend_from_slice(&(key.len() as u64).to_be_bytes());
126        chunk.extend_from_slice(key);
127        entries += 1;
128        if chunk::cut_after(key, entries) {
129            chunks.push(std::mem::take(&mut chunk));
130            entries = 0;
131        }
132    }
133    if !chunk.is_empty() {
134        chunks.push(chunk);
135    }
136    chunks
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::digest;
143
144    fn keys(count: u64) -> Vec<Vec<u8>> {
145        (0..count).map(|n| n.to_be_bytes().to_vec()).collect()
146    }
147
148    #[test]
149    fn manifest_round_trips() {
150        let children = vec![digest(b"a"), digest(b"b")];
151        let encoded = encode_index_manifest(&children);
152        assert!(is_index_manifest(&encoded));
153        assert_eq!(decode_index_manifest(&encoded).unwrap(), children);
154        assert_eq!(index_blob_children(&encoded).unwrap(), children);
155    }
156
157    #[test]
158    fn empty_manifest_round_trips() {
159        let encoded = encode_index_manifest(&[]);
160        assert!(decode_index_manifest(&encoded).unwrap().is_empty());
161    }
162
163    #[test]
164    fn flat_key_stream_is_not_a_manifest_and_has_no_children() {
165        let mut flat = Vec::new();
166        flat.extend_from_slice(&4u64.to_be_bytes());
167        flat.extend_from_slice(b"key0");
168        assert!(!is_index_manifest(&flat));
169        assert!(index_blob_children(&flat).unwrap().is_empty());
170        assert!(index_blob_children(&[]).unwrap().is_empty());
171    }
172
173    #[test]
174    fn corrupt_manifest_is_an_error_not_a_flat_blob() {
175        let mut encoded = encode_index_manifest(&[digest(b"a")]);
176        encoded.extend_from_slice(b"not-a-blob-id\n");
177        assert!(decode_index_manifest(&encoded).is_err());
178        assert!(index_blob_children(&encoded).is_err());
179    }
180
181    #[test]
182    fn chunks_concatenate_back_to_the_input() {
183        let keys = keys(10_000);
184        let chunks = chunk_segment_keys(keys.iter().map(Vec::as_slice));
185        assert!(chunks.len() > 1, "10k keys should span several chunks");
186        let mut decoded = Vec::new();
187        for chunk in &chunks {
188            let mut input = chunk.as_slice();
189            while !input.is_empty() {
190                let (len, rest) = input.split_at(8);
191                let len = usize::try_from(u64::from_be_bytes(len.try_into().unwrap())).unwrap();
192                let (key, rest) = rest.split_at(len);
193                decoded.push(key.to_vec());
194                input = rest;
195            }
196        }
197        assert_eq!(decoded, keys);
198    }
199
200    #[test]
201    fn encoding_a_chunks_keys_matches_the_chunker() {
202        let keys = keys(10_000);
203        let chunks = chunk_segment_keys(keys.iter().map(Vec::as_slice));
204        let mut offset = 0;
205        for chunk in &chunks {
206            let count = decode_segment_keys(chunk).unwrap().len();
207            let encoded =
208                encode_segment_chunk(keys[offset..offset + count].iter().map(Vec::as_slice));
209            assert_eq!(&encoded, chunk);
210            offset += count;
211        }
212        assert_eq!(offset, keys.len());
213    }
214
215    #[test]
216    fn chunking_is_deterministic() {
217        let keys = keys(5_000);
218        let first = chunk_segment_keys(keys.iter().map(Vec::as_slice));
219        let second = chunk_segment_keys(keys.iter().map(Vec::as_slice));
220        assert_eq!(first, second);
221    }
222
223    #[test]
224    fn empty_input_yields_no_chunks() {
225        assert!(chunk_segment_keys(std::iter::empty()).is_empty());
226    }
227
228    #[test]
229    fn appending_keys_reuses_every_settled_chunk() {
230        let original = keys(10_000);
231        let extended = keys(10_100);
232        let before: Vec<BlobId> = chunk_segment_keys(original.iter().map(Vec::as_slice))
233            .iter()
234            .map(|chunk| digest(chunk))
235            .collect();
236        let after: Vec<BlobId> = chunk_segment_keys(extended.iter().map(Vec::as_slice))
237            .iter()
238            .map(|chunk| digest(chunk))
239            .collect();
240        // Every chunk except the tail the append landed in is shared.
241        let shared = after.iter().filter(|id| before.contains(id)).count();
242        assert!(
243            shared >= before.len() - 1,
244            "expected at least {} shared chunks, found {shared}",
245            before.len() - 1
246        );
247    }
248
249    #[test]
250    fn inserting_a_key_rechunks_only_its_neighborhood() {
251        let original = keys(10_000);
252        let mut modified = original.clone();
253        modified.insert(4_000, b"inserted-key".to_vec());
254        let before: Vec<BlobId> = chunk_segment_keys(original.iter().map(Vec::as_slice))
255            .iter()
256            .map(|chunk| digest(chunk))
257            .collect();
258        let after: Vec<BlobId> = chunk_segment_keys(modified.iter().map(Vec::as_slice))
259            .iter()
260            .map(|chunk| digest(chunk))
261            .collect();
262        let fresh = after.iter().filter(|id| !before.contains(id)).count();
263        assert!(
264            fresh <= 2,
265            "one insertion should rewrite at most two chunks, rewrote {fresh} of {}",
266            after.len()
267        );
268    }
269}