Skip to main content

limnifs_write/
compaction.rs

1//! Slab compaction — removes unreferenced drops from a slab without
2//! re-reading file contents. Preserves codecs, drop identities, and
3//! metadata; only the slab and manifest's slab index / history change.
4//!
5//! ## Algorithm
6//!
7//! 1. Parse the source manifest's prefix (header, flags, metadata
8//!    reference — these are copied verbatim).
9//! 2. Walk the metadata blob to find all referenced `DropId`s.
10//! 3. Load the source slab, extract referenced drops with their
11//!    compressed bytes and codec (no decompression needed).
12//! 4. Build a new slab containing only the referenced drops.
13//! 5. Re-encode the slab index + history.
14//! 6. Recompute the Merkle root.
15
16use std::collections::HashSet;
17
18use limnifs_core::{
19    compute_merkle_root, hash_empty_section, hash_section, parse_feature_flags_section,
20    parse_manifest_header, parse_metadata_blob, parse_metadata_reference, parse_slab_index,
21    ContentHandle, CoreError, ManifestCursor, SectionHashes, HISTORY_SECTION_VERSION,
22    SLAB_INDEX_SECTION_VERSION,
23};
24use limnifs_format::{ManifestRoot, SlabId};
25
26/// Result of compacting an image.
27#[derive(Clone, Debug)]
28pub struct CompactionResult {
29    pub manifest_bytes: Vec<u8>,
30    pub merkle_root: ManifestRoot,
31    pub slab_bytes: Option<Vec<u8>>,
32    pub original_drop_count: usize,
33    pub compacted_drop_count: usize,
34    pub reclaimed_drops: usize,
35}
36
37/// Error during compaction.
38#[derive(Debug)]
39pub enum CompactionError {
40    Core(CoreError),
41    Io(std::io::Error),
42}
43
44impl std::fmt::Display for CompactionError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Core(e) => write!(f, "{e}"),
48            Self::Io(e) => write!(f, "I/O: {e}"),
49        }
50    }
51}
52
53impl std::error::Error for CompactionError {}
54
55impl From<CoreError> for CompactionError {
56    fn from(e: CoreError) -> Self {
57        Self::Core(e)
58    }
59}
60
61impl From<std::io::Error> for CompactionError {
62    fn from(e: std::io::Error) -> Self {
63        Self::Io(e)
64    }
65}
66
67/// One drop extracted from the source slab, ready for re-packing.
68struct ExtractedDrop {
69    id: [u8; 32],
70    compressed: Vec<u8>,
71    codec: u8,
72    plaintext_len: u32,
73    /// Record flags carried through from the source slab (bit0 =
74    /// SEEKABLE container).
75    flags: u8,
76}
77
78/// Compact an image by removing unreferenced drops from its slab.
79/// The manifest's metadata, header, and flags are preserved; only the
80/// slab and the slab index / history sections are updated.
81///
82/// # Errors
83///
84/// Returns [`CompactionError`] if the image or slab cannot be parsed.
85pub fn compact_image(
86    manifest_bytes: &[u8],
87    slab_bytes: &[u8],
88) -> Result<CompactionResult, CompactionError> {
89    // 1. Parse the manifest prefix to find referenced drops.
90    let mut cursor = ManifestCursor::new(manifest_bytes);
91    let _header = parse_manifest_header(&mut cursor)?;
92
93    // Capture the raw prefix bytes (header + flags + metadata ref)
94    // for verbatim re-encoding.
95    let prefix_end = {
96        let flags_start = cursor.position();
97        let _ = parse_feature_flags_section(&mut cursor)?;
98        let flags_end = cursor.position();
99        let meta_ref_start = cursor.position();
100        let meta_ref = parse_metadata_reference(&mut cursor)?;
101        let _meta_ref_end = cursor.position();
102        let _ = (flags_start, flags_end, meta_ref_start);
103
104        // Walk inodes to find referenced drops.
105        let referenced = find_referenced_drops(&meta_ref)?;
106
107        // Parse slab index to capture its section bytes.
108        let slab_index_start = cursor.position();
109        let slab_index = parse_slab_index(&mut cursor)?;
110        let slab_index_end = cursor.position();
111        let _ = (slab_index_start, slab_index_end, &slab_index);
112
113        referenced
114    };
115
116    // 2. Parse and compact the slab using parse_slab (correctly
117    //    handles the record-then-window layout).
118    let view = limnifs_core::parse_slab(slab_bytes)?;
119    let original_count = view.drop_records().len();
120    let win_start = view.solid_window_offset();
121    let mut kept_drops: Vec<ExtractedDrop> = Vec::new();
122
123    for record in view.drop_records() {
124        if prefix_end.contains(record.drop_id.as_bytes()) {
125            let offset = usize::try_from(record.offset_in_window).unwrap_or(0);
126            let len = usize::try_from(record.len_in_window).unwrap_or(0);
127            let start = win_start + offset;
128            let end = start + len;
129            if end > slab_bytes.len() {
130                continue;
131            }
132            kept_drops.push(ExtractedDrop {
133                id: *record.drop_id.as_bytes(),
134                compressed: slab_bytes[start..end].to_vec(),
135                codec: record.representation.codec,
136                plaintext_len: record.plaintext_len,
137                flags: record.flags,
138            });
139        }
140    }
141
142    let compacted_count = kept_drops.len();
143    let reclaimed = original_count.saturating_sub(compacted_count);
144
145    // 3. Build the compacted slab.
146    let (new_slab_bytes, new_slab_id) = encode_compacted_slab(&kept_drops);
147
148    // 4. Re-assemble the manifest.
149    let new_manifest = reassemble_manifest(manifest_bytes, &new_slab_bytes, &new_slab_id)?;
150
151    Ok(CompactionResult {
152        manifest_bytes: new_manifest.bytes,
153        merkle_root: new_manifest.merkle_root,
154        slab_bytes: Some(new_slab_bytes),
155        original_drop_count: original_count,
156        compacted_drop_count: compacted_count,
157        reclaimed_drops: reclaimed,
158    })
159}
160
161/// Walk the metadata blob to find all referenced `DropIds`.
162///
163/// Intentionally defensive: this is NOT a full `walk_live_tree` call
164/// because compaction only needs the set of referenced `DropId`s —
165/// the per-path Sink structure would be pure overhead. We iterate
166/// inodes directly and pull `DropId`s from each `SliceMap` content
167/// handle. Doc-noted per IMPL-5 (TODO.remaining).
168fn find_referenced_drops(
169    meta_ref: &limnifs_core::MetadataReference,
170) -> Result<HashSet<[u8; 32]>, CompactionError> {
171    let mut referenced = HashSet::new();
172    if let Some(blob_bytes) = &meta_ref.inline_metadata {
173        let mut blob_cursor = ManifestCursor::new(blob_bytes);
174        let blob = parse_metadata_blob(&mut blob_cursor)?;
175        for inode in &blob.inodes {
176            if let ContentHandle::SliceMap(slices) = &inode.content_handle {
177                for slice in slices {
178                    referenced.insert(*slice.drop_id.as_bytes());
179                }
180            }
181        }
182    }
183    Ok(referenced)
184}
185
186/// Encode a compacted slab from extracted drops.
187fn encode_compacted_slab(drops: &[ExtractedDrop]) -> (Vec<u8>, SlabId) {
188    let mut drop_records = Vec::new();
189    let mut solid_window = Vec::new();
190
191    for drop in drops {
192        let win_len = u32::try_from(drop.compressed.len()).unwrap_or(0);
193        let offset = u32::try_from(solid_window.len()).unwrap_or(0);
194        drop_records.extend_from_slice(&drop.id);
195        drop_records.extend_from_slice(&drop.plaintext_len.to_le_bytes());
196        drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]); // (codec, aead=0, ec=0)
197        drop_records.push(0x00); // solid_window_index
198        drop_records.extend_from_slice(&offset.to_le_bytes());
199        drop_records.extend_from_slice(&win_len.to_le_bytes());
200        drop_records.push(limnifs_core::drop_record::NO_DICT); // dict_id: no dictionary
201        drop_records.push(drop.flags); // flags: bit0 = SEEKABLE container
202        solid_window.extend_from_slice(&drop.compressed);
203    }
204
205    let slab_content = [&drop_records[..], &solid_window[..]].concat();
206    let slab_hash = hash_section(&slab_content);
207    let slab_id = SlabId::new(0, slab_hash);
208
209    let total_length = 56 + slab_content.len();
210    let mut slab_bytes = Vec::with_capacity(total_length);
211    slab_bytes.extend_from_slice(b"LIM1");
212    slab_bytes.extend_from_slice(&1u16.to_le_bytes()); // the slab format version
213    slab_bytes.extend_from_slice(&slab_id.to_bytes());
214    slab_bytes.extend_from_slice(&(total_length as u64).to_le_bytes());
215    slab_bytes.push(0x00); // ec_descriptor
216    slab_bytes.push(0x00); // crypto_hint
217    slab_bytes.extend_from_slice(&slab_content);
218
219    (slab_bytes, slab_id)
220}
221
222struct ReassembledManifest {
223    bytes: Vec<u8>,
224    merkle_root: ManifestRoot,
225}
226
227/// Re-assemble the manifest with updated slab index.
228fn reassemble_manifest(
229    source: &[u8],
230    _slab_bytes: &[u8],
231    slab_id: &SlabId,
232) -> Result<ReassembledManifest, CompactionError> {
233    let mut cursor = ManifestCursor::new(source);
234
235    // Re-parse and capture each section's raw bytes.
236    let header_start = cursor.position();
237    let _ = parse_manifest_header(&mut cursor)?;
238    let header_end = cursor.position();
239
240    let flags_start = cursor.position();
241    let _ = parse_feature_flags_section(&mut cursor)?;
242    let flags_end = cursor.position();
243
244    let meta_ref_start = cursor.position();
245    let meta_ref = parse_metadata_reference(&mut cursor)?;
246    let meta_ref_end = cursor.position();
247
248    // Skip old slab index and history.
249    let _ = parse_slab_index(&mut cursor)?;
250    let _ = limnifs_core::parse_history(&mut cursor)?;
251
252    // Build new manifest.
253    let mut manifest = Vec::new();
254
255    // Copy header + flags + metadata reference verbatim.
256    manifest.extend_from_slice(&source[header_start..meta_ref_end]);
257
258    // New slab index.
259    let slab_index_start_new = manifest.len();
260    manifest.push(SLAB_INDEX_SECTION_VERSION);
261    manifest.extend_from_slice(&1u32.to_le_bytes());
262    manifest.extend_from_slice(&slab_id.to_bytes());
263    manifest.extend_from_slice(&1u32.to_le_bytes());
264    let locator = "file:slab-0.bin";
265    manifest.extend_from_slice(&u32::try_from(locator.len()).unwrap_or(0).to_le_bytes());
266    manifest.extend_from_slice(locator.as_bytes());
267    let slab_index_end_new = manifest.len();
268
269    // History.
270    let history_start_new = manifest.len();
271    manifest.push(HISTORY_SECTION_VERSION);
272    manifest.extend_from_slice(&1u32.to_le_bytes());
273    manifest.push(0x04); // turnover
274    manifest.extend_from_slice(&0u64.to_le_bytes());
275    manifest.extend_from_slice(&0u32.to_le_bytes());
276    manifest.extend_from_slice(&0u32.to_le_bytes());
277    let history_end_new = manifest.len();
278
279    let hashes = SectionHashes {
280        metadata: meta_ref.metadata_hash,
281        format_header: hash_section(&source[header_start..header_end]),
282        feature_flags: hash_section(&source[flags_start..flags_end]),
283        metadata_reference: hash_section(&source[meta_ref_start..meta_ref_end]),
284        slab_index: hash_section(&manifest[slab_index_start_new..slab_index_end_new]),
285        crypto_params: hash_empty_section(),
286        ec_params: hash_empty_section(),
287        dms_policy: hash_empty_section(),
288        delta_linkage: hash_empty_section(),
289        history: hash_section(&manifest[history_start_new..history_end_new]),
290    };
291    let merkle_root = compute_merkle_root(&hashes);
292
293    Ok(ReassembledManifest {
294        bytes: manifest,
295        merkle_root,
296    })
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn compact_preserves_referenced_drops() {
305        let temp =
306            std::env::temp_dir().join(format!("limnifs-compaction-test-{}", std::process::id()));
307        std::fs::create_dir_all(&temp).expect("create temp");
308        // Use large enough data to trigger slab-backed storage.
309        let data = vec![0xABu8; crate::INLINE_THRESHOLD + 100];
310        std::fs::write(temp.join("big.bin"), &data).expect("write big");
311
312        let artifact = crate::write_directory(&temp).expect("write");
313        std::fs::remove_dir_all(&temp).ok();
314
315        let slab_bytes = artifact.slab_bytes().map(Vec::from).unwrap_or_default();
316        if slab_bytes.is_empty() {
317            return;
318        }
319
320        let result = compact_image(&artifact.bytes, &slab_bytes).expect("compact");
321        assert_eq!(result.original_drop_count, result.compacted_drop_count);
322        assert_eq!(result.reclaimed_drops, 0);
323
324        // Verify the compacted slab parses correctly.
325        let new_slab = result.slab_bytes.as_ref().expect("slab exists");
326        let view = limnifs_core::parse_slab(new_slab).expect("compacted slab parses");
327        assert_eq!(view.drop_records().len(), result.compacted_drop_count);
328    }
329
330    #[test]
331    fn compact_preserves_drop_plaintext() {
332        let temp =
333            std::env::temp_dir().join(format!("limnifs-compaction-pt-{}", std::process::id()));
334        std::fs::create_dir_all(&temp).expect("create temp");
335        let data = vec![0xCDu8; crate::INLINE_THRESHOLD + 200];
336        std::fs::write(temp.join("data.bin"), &data).expect("write");
337
338        let artifact = crate::write_directory(&temp).expect("write");
339        std::fs::remove_dir_all(&temp).ok();
340
341        let slab_bytes = artifact.slab_bytes().map(Vec::from).unwrap_or_default();
342        if slab_bytes.is_empty() {
343            return;
344        }
345
346        let result = compact_image(&artifact.bytes, &slab_bytes).expect("compact");
347        let new_slab = result.slab_bytes.as_ref().expect("slab");
348
349        // Parse old slab and get plaintext.
350        let old_view = limnifs_core::parse_slab(&slab_bytes).expect("old slab parses");
351        let new_view = limnifs_core::parse_slab(new_slab).expect("new slab parses");
352
353        for old_record in old_view.drop_records() {
354            let old_pt = old_view
355                .plaintext_for(old_record.drop_id.as_bytes())
356                .expect("old drop exists")
357                .expect("decompress ok");
358            let new_pt = new_view
359                .plaintext_for(old_record.drop_id.as_bytes())
360                .expect("new drop exists")
361                .expect("decompress ok");
362            assert_eq!(old_pt, new_pt, "plaintext must match after compaction");
363        }
364    }
365
366    #[test]
367    fn compact_manifest_parses_correctly() {
368        let temp =
369            std::env::temp_dir().join(format!("limnifs-compaction-mp-{}", std::process::id()));
370        std::fs::create_dir_all(&temp).expect("create temp");
371        let data = vec![0xEFu8; crate::INLINE_THRESHOLD + 50];
372        std::fs::write(temp.join("file.bin"), &data).expect("write");
373
374        let artifact = crate::write_directory(&temp).expect("write");
375        std::fs::remove_dir_all(&temp).ok();
376
377        let slab_bytes = artifact.slab_bytes().map(Vec::from).unwrap_or_default();
378        if slab_bytes.is_empty() {
379            return;
380        }
381
382        let result = compact_image(&artifact.bytes, &slab_bytes).expect("compact");
383
384        // Verify the compacted manifest parses end-to-end.
385        let mut cursor = limnifs_core::ManifestCursor::new(&result.manifest_bytes);
386        limnifs_core::parse_manifest_header(&mut cursor).expect("header parses");
387        limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags parse");
388        let meta_ref =
389            limnifs_core::parse_metadata_reference(&mut cursor).expect("metadata ref parses");
390        assert!(meta_ref.is_inlined());
391        limnifs_core::parse_slab_index(&mut cursor).expect("slab index parses");
392        limnifs_core::parse_history(&mut cursor).expect("history parses");
393    }
394}