Skip to main content

limnifs_write/
delta_builder.rs

1//! Delta builder — computes tree operations between two images.
2//!
3//! Given a parent image and a child image, produces the tree
4//! operations (Add / Remove / Replace) that transform the parent's
5//! filesystem tree into the child's. The output is a delta linkage
6//! section (spec §5.8) that can be appended to a manifest.
7//!
8//! ## Algorithm
9//!
10//! 1. Parse both images' metadata blobs to obtain their directory trees.
11//! 2. Walk both trees simultaneously from their root inodes.
12//! 3. At each directory, compare entry sets:
13//!    - Entry only in child → emit `Add(path, child_inode)`.
14//!    - Entry only in parent → emit `Remove(path)`.
15//!    - Entry in both with different inode content → emit
16//!      `Replace(path, child_inode)`.
17//!    - Entry in both with same content → recurse if both are
18//!      directories, otherwise no-op.
19//! 4. Collect all operations in deterministic order (sorted by path).
20//!
21//! Content identity is determined by the inode's content handle:
22//! - Directories: BLAKE3 hash of the directory node bytes.
23//! - Files: the drop ID (for drop-backed) or BLAKE3 of inline data.
24//! - Other types: compared by full inode equality.
25//!
26//! Two inodes with the same content but different inode numbers are
27//! NOT considered different (they map to the same bytes on disk).
28
29use std::collections::BTreeMap;
30use std::path::Path;
31
32use limnifs_core::delta_linkage::{TreeOp, TreeOpKind};
33use limnifs_core::{
34    parse_manifest_header, parse_metadata_blob, parse_metadata_reference, ContentHandle, CoreError,
35    Inode, ManifestCursor, MetadataBlob,
36};
37
38/// Error during delta computation.
39#[derive(Debug)]
40pub enum DeltaError {
41    Core(CoreError),
42    Io(std::io::Error),
43}
44
45impl std::fmt::Display for DeltaError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Core(e) => write!(f, "format error: {e}"),
49            Self::Io(e) => write!(f, "I/O error: {e}"),
50        }
51    }
52}
53
54impl std::error::Error for DeltaError {}
55
56impl From<CoreError> for DeltaError {
57    fn from(e: CoreError) -> Self {
58        Self::Core(e)
59    }
60}
61
62impl From<std::io::Error> for DeltaError {
63    fn from(e: std::io::Error) -> Self {
64        Self::Io(e)
65    }
66}
67
68impl From<crate::WriteError> for DeltaError {
69    fn from(e: crate::WriteError) -> Self {
70        match e {
71            crate::WriteError::Io(io) => Self::Io(io),
72        }
73    }
74}
75
76/// Result of computing a delta: the tree operations + the parent's
77/// `ManifestRoot` (to embed in the delta linkage section's `base_root`).
78#[derive(Clone, Debug)]
79pub struct DeltaArtifact {
80    pub tree_ops: Vec<TreeOp>,
81    pub base_root: [u8; 32],
82}
83
84impl DeltaArtifact {
85    /// Encode the delta as a delta linkage section (spec §5.8).
86    ///
87    /// # Panics
88    ///
89    /// Panics if the tree-op count or any path length exceeds `u32`.
90    /// Both are bounded by the metadata blob's inode count and path
91    /// lengths, which the writer validates upstream.
92    #[must_use]
93    pub fn to_section_bytes(&self) -> Vec<u8> {
94        let mut bytes = Vec::new();
95        bytes.push(1u8); // section_version
96        bytes.extend_from_slice(&self.base_root);
97        let count = u32::try_from(self.tree_ops.len()).expect("tree_op_count fits u32");
98        bytes.extend_from_slice(&count.to_le_bytes());
99        for op in &self.tree_ops {
100            bytes.push(op.kind as u8);
101            let path_bytes = op.path.as_bytes();
102            let path_len = u32::try_from(path_bytes.len()).expect("path fits u32");
103            bytes.extend_from_slice(&path_len.to_le_bytes());
104            bytes.extend_from_slice(path_bytes);
105            if let Some(inode_number) = op.inode_number {
106                bytes.extend_from_slice(&inode_number.to_le_bytes());
107            }
108        }
109        bytes
110    }
111}
112
113/// Compute the delta between a parent image and a child image. Both
114/// are `.lim` manifest files on disk.
115///
116/// # Errors
117///
118/// Returns [`DeltaError`] if either image fails to parse or is not
119/// an inlined-metadata image.
120pub fn compute_delta(parent_path: &Path, child_path: &Path) -> Result<DeltaArtifact, DeltaError> {
121    let parent_bytes = std::fs::read(parent_path)?;
122    let child_bytes = std::fs::read(child_path)?;
123    compute_delta_from_bytes(&parent_bytes, &child_bytes)
124}
125
126/// Same as [`compute_delta`] but takes raw manifest bytes instead of
127/// file paths. Useful for testing.
128///
129/// # Errors
130///
131/// Returns [`DeltaError`] if either image fails to parse or is not
132/// an inlined-metadata image.
133///
134/// # Panics
135///
136/// Panics if either image's root inode is missing after validation
137/// (cannot happen — `load_image` validates it before returning).
138pub fn compute_delta_from_bytes(
139    parent_bytes: &[u8],
140    child_bytes: &[u8],
141) -> Result<DeltaArtifact, DeltaError> {
142    let (parent_blob, parent_root_number, parent_merkle_root) = load_image(parent_bytes)?;
143    let (child_blob, child_root_number, _) = load_image(child_bytes)?;
144
145    let parent_root_inode = parent_blob
146        .inode_by_number(parent_root_number)
147        .expect("load_image validates root inode exists");
148    let child_root_inode = child_blob
149        .inode_by_number(child_root_number)
150        .expect("load_image validates root inode exists");
151
152    let mut ops = Vec::new();
153    diff_directory(
154        &parent_blob,
155        &child_blob,
156        parent_root_inode,
157        child_root_inode,
158        "",
159        &mut ops,
160    );
161    ops.sort_by(|a, b| a.path.cmp(&b.path));
162
163    Ok(DeltaArtifact {
164        tree_ops: ops,
165        base_root: parent_merkle_root,
166    })
167}
168
169/// Load an image: parse manifest header, metadata reference, extract
170/// the inlined metadata blob, find the root inode number, and read
171/// the Merkle root from the manifest.
172fn load_image(bytes: &[u8]) -> Result<(MetadataBlob, u64, [u8; 32]), DeltaError> {
173    let mut cursor = ManifestCursor::new(bytes);
174    let header = parse_manifest_header(&mut cursor)?;
175    let _ = header;
176    let _ = limnifs_core::parse_feature_flags_section(&mut cursor)?;
177    let meta_ref = parse_metadata_reference(&mut cursor)?;
178    let blob_bytes = meta_ref.inline_metadata.as_deref().ok_or_else(|| {
179        DeltaError::Core(CoreError::Corrupt {
180            reason: "delta builder requires inlined metadata".into(),
181        })
182    })?;
183    let mut blob_cursor = ManifestCursor::new(blob_bytes);
184    let blob = parse_metadata_blob(&mut blob_cursor)?;
185
186    let root_number = blob.root_inode_number().ok_or_else(|| {
187        DeltaError::Core(CoreError::Corrupt {
188            reason: "metadata blob: could not identify a unique root directory inode".into(),
189        })
190    })?;
191    if blob.inode_by_number(root_number).is_none() {
192        return Err(DeltaError::Core(CoreError::Corrupt {
193            reason: format!("metadata blob: root inode {root_number} missing"),
194        }));
195    }
196
197    let mut merkle_root = [0u8; 32];
198    // The delta builder needs the parent's ManifestRoot. For now we
199    // hash the full manifest as a proxy. The caller should supply the
200    // correct base_root if precision matters (e.g. via verify).
201    limnifs_core::hash_section(bytes).clone_into(&mut merkle_root);
202
203    Ok((blob, root_number, merkle_root))
204}
205
206/// Recursively diff two directory inodes, appending `TreeOps` for
207/// every difference found.
208fn diff_directory(
209    parent_blob: &MetadataBlob,
210    child_blob: &MetadataBlob,
211    parent_inode: &Inode,
212    child_inode: &Inode,
213    path: &str,
214    ops: &mut Vec<TreeOp>,
215) {
216    let p_hash = match &parent_inode.content_handle {
217        ContentHandle::Directory(h) => *h,
218        _ => return,
219    };
220    let c_hash = match &child_inode.content_handle {
221        ContentHandle::Directory(h) => *h,
222        _ => return,
223    };
224
225    let Some(p_node) = parent_blob.dir_node_by_hash(&p_hash) else {
226        return;
227    };
228    let Some(c_node) = child_blob.dir_node_by_hash(&c_hash) else {
229        return;
230    };
231
232    // Build lookup maps: name → inode_number.
233    let parent_entries: BTreeMap<&str, u64> = p_node
234        .entries
235        .iter()
236        .map(|e| (e.name.as_str(), e.inode_number))
237        .collect();
238    let child_entries: BTreeMap<&str, u64> = c_node
239        .entries
240        .iter()
241        .map(|e| (e.name.as_str(), e.inode_number))
242        .collect();
243
244    for (name, child_inum) in &child_entries {
245        let child_path = if path.is_empty() {
246            (*name).to_owned()
247        } else {
248            format!("{path}/{name}")
249        };
250        match parent_entries.get(*name) {
251            None => {
252                // Entry only in child → Add.
253                ops.push(TreeOp {
254                    kind: TreeOpKind::Add,
255                    path: child_path,
256                    inode_number: Some(*child_inum),
257                });
258            }
259            Some(parent_inum) => {
260                // Entry in both — compare content.
261                let parent_child_inode = parent_blob.inode_by_number(*parent_inum);
262                let child_child_inode = child_blob.inode_by_number(*child_inum);
263                if let (Some(pci), Some(cci)) = (parent_child_inode, child_child_inode) {
264                    if pci.is_directory() && cci.is_directory() {
265                        // Always recurse into matching directories to
266                        // find per-entry deltas. Replaces are only
267                        // emitted for files, not directories — a
268                        // directory "change" is expressed as Adds /
269                        // Removes / Replaces on its children.
270                        diff_directory(parent_blob, child_blob, pci, cci, &child_path, ops);
271                    } else if !inodes_equal(pci, cci) {
272                        ops.push(TreeOp {
273                            kind: TreeOpKind::Replace,
274                            path: child_path,
275                            inode_number: Some(*child_inum),
276                        });
277                    }
278                }
279            }
280        }
281    }
282
283    for name in parent_entries.keys() {
284        if !child_entries.contains_key(*name) {
285            let parent_path = if path.is_empty() {
286                (*name).to_owned()
287            } else {
288                format!("{path}/{name}")
289            };
290            ops.push(TreeOp {
291                kind: TreeOpKind::Remove,
292                path: parent_path,
293                inode_number: None,
294            });
295        }
296    }
297}
298
299/// Determine if two inodes have the same content (identity). Two
300/// inodes are identical iff their content handles produce the same
301/// bytes — regardless of their inode numbers.
302fn inodes_equal(a: &Inode, b: &Inode) -> bool {
303    if a.file_type() != b.file_type() {
304        return false;
305    }
306    match (&a.content_handle, &b.content_handle) {
307        (ContentHandle::InlineData(da), ContentHandle::InlineData(db)) => da == db,
308        (ContentHandle::SliceMap(sa), ContentHandle::SliceMap(sb)) => {
309            // Compare by the drop IDs each slice references. Two
310            // files are identical iff their slice maps reference the
311            // same drops in the same order.
312            if sa.len() != sb.len() {
313                return false;
314            }
315            sa.iter()
316                .zip(sb.iter())
317                .all(|(x, y)| x.drop_id.as_bytes() == y.drop_id.as_bytes())
318        }
319        (ContentHandle::Directory(ha), ContentHandle::Directory(hb)) => ha == hb,
320        (ContentHandle::Symlink(ta), ContentHandle::Symlink(tb)) => ta == tb,
321        (ContentHandle::Device(da), ContentHandle::Device(db)) => da == db,
322        (ContentHandle::Pipe(pa), ContentHandle::Pipe(pb)) => pa == pb,
323        _ => false,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn write_image(dir: &Path) -> Result<Vec<u8>, DeltaError> {
332        let artifact = crate::write_directory(dir)?;
333        Ok(artifact.bytes)
334    }
335
336    #[test]
337    fn identical_images_produce_empty_delta() {
338        let temp = std::env::temp_dir().join(format!(
339            "limnifs-delta-test-{}-identical",
340            std::process::id()
341        ));
342        std::fs::create_dir_all(&temp).expect("create temp");
343        std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
344        let bytes = write_image(&temp).expect("write image");
345        std::fs::remove_dir_all(&temp).ok();
346
347        let delta = compute_delta_from_bytes(&bytes, &bytes).expect("delta computes");
348        assert!(
349            delta.tree_ops.is_empty(),
350            "identical images should have no ops"
351        );
352    }
353
354    #[test]
355    fn added_file_produces_add_op() {
356        let parent_dir = std::env::temp_dir().join(format!(
357            "limnifs-delta-test-{}-parent-add",
358            std::process::id()
359        ));
360        let child_dir = std::env::temp_dir().join(format!(
361            "limnifs-delta-test-{}-child-add",
362            std::process::id()
363        ));
364        std::fs::create_dir_all(&parent_dir).expect("create parent");
365        std::fs::create_dir_all(&child_dir).expect("create child");
366        std::fs::write(parent_dir.join("a.txt"), b"aaa").expect("write a");
367        std::fs::write(child_dir.join("a.txt"), b"aaa").expect("copy a");
368        std::fs::write(child_dir.join("b.txt"), b"bbb").expect("write b");
369
370        let parent_bytes = write_image(&parent_dir).expect("parent");
371        let child_bytes = write_image(&child_dir).expect("child");
372        std::fs::remove_dir_all(&parent_dir).ok();
373        std::fs::remove_dir_all(&child_dir).ok();
374
375        let delta = compute_delta_from_bytes(&parent_bytes, &child_bytes).expect("delta");
376        let adds: Vec<&TreeOp> = delta
377            .tree_ops
378            .iter()
379            .filter(|op| op.kind == TreeOpKind::Add)
380            .collect();
381        assert_eq!(adds.len(), 1, "expected exactly one Add op");
382        assert_eq!(adds[0].path, "b.txt");
383        assert!(adds[0].inode_number.is_some());
384    }
385
386    #[test]
387    fn removed_file_produces_remove_op() {
388        let parent_dir = std::env::temp_dir().join(format!(
389            "limnifs-delta-test-{}-parent-rm",
390            std::process::id()
391        ));
392        let child_dir = std::env::temp_dir().join(format!(
393            "limnifs-delta-test-{}-child-rm",
394            std::process::id()
395        ));
396        std::fs::create_dir_all(&parent_dir).expect("create parent");
397        std::fs::create_dir_all(&child_dir).expect("create child");
398        std::fs::write(parent_dir.join("a.txt"), b"aaa").expect("write a");
399        std::fs::write(parent_dir.join("b.txt"), b"bbb").expect("write b");
400        std::fs::write(child_dir.join("a.txt"), b"aaa").expect("copy a");
401
402        let parent_bytes = write_image(&parent_dir).expect("parent");
403        let child_bytes = write_image(&child_dir).expect("child");
404        std::fs::remove_dir_all(&parent_dir).ok();
405        std::fs::remove_dir_all(&child_dir).ok();
406
407        let delta = compute_delta_from_bytes(&parent_bytes, &child_bytes).expect("delta");
408        let removes: Vec<&TreeOp> = delta
409            .tree_ops
410            .iter()
411            .filter(|op| op.kind == TreeOpKind::Remove)
412            .collect();
413        assert_eq!(removes.len(), 1, "expected exactly one Remove op");
414        assert_eq!(removes[0].path, "b.txt");
415        assert!(removes[0].inode_number.is_none());
416    }
417
418    #[test]
419    fn modified_file_produces_replace_op() {
420        let parent_dir = std::env::temp_dir().join(format!(
421            "limnifs-delta-test-{}-parent-mod",
422            std::process::id()
423        ));
424        let child_dir = std::env::temp_dir().join(format!(
425            "limnifs-delta-test-{}-child-mod",
426            std::process::id()
427        ));
428        std::fs::create_dir_all(&parent_dir).expect("create parent");
429        std::fs::create_dir_all(&child_dir).expect("create child");
430        std::fs::write(parent_dir.join("a.txt"), b"original").expect("write a");
431        std::fs::write(child_dir.join("a.txt"), b"modified").expect("write a");
432
433        let parent_bytes = write_image(&parent_dir).expect("parent");
434        let child_bytes = write_image(&child_dir).expect("child");
435        std::fs::remove_dir_all(&parent_dir).ok();
436        std::fs::remove_dir_all(&child_dir).ok();
437
438        let delta = compute_delta_from_bytes(&parent_bytes, &child_bytes).expect("delta");
439        let replaces: Vec<&TreeOp> = delta
440            .tree_ops
441            .iter()
442            .filter(|op| op.kind == TreeOpKind::Replace)
443            .collect();
444        assert_eq!(replaces.len(), 1, "expected exactly one Replace op");
445        assert_eq!(replaces[0].path, "a.txt");
446        assert!(replaces[0].inode_number.is_some());
447    }
448
449    #[test]
450    fn delta_section_bytes_round_trip() {
451        let ops = vec![
452            TreeOp {
453                kind: TreeOpKind::Add,
454                path: "new.txt".into(),
455                inode_number: Some(42),
456            },
457            TreeOp {
458                kind: TreeOpKind::Remove,
459                path: "old.txt".into(),
460                inode_number: None,
461            },
462        ];
463        let artifact = DeltaArtifact {
464            tree_ops: ops,
465            base_root: [0xAB; 32],
466        };
467        let bytes = artifact.to_section_bytes();
468
469        let mut cursor = ManifestCursor::new(&bytes);
470        let parsed = limnifs_core::delta_linkage::parse_delta_linkage(&mut cursor).expect("parses");
471        assert_eq!(parsed.tree_ops.len(), 2);
472        assert_eq!(parsed.tree_ops[0].path, "new.txt");
473        assert_eq!(parsed.tree_ops[0].inode_number, Some(42));
474        assert_eq!(parsed.tree_ops[1].path, "old.txt");
475        assert_eq!(parsed.tree_ops[1].inode_number, None);
476    }
477
478    #[test]
479    fn subdirectory_changes_produce_nested_ops() {
480        let parent_dir = std::env::temp_dir().join(format!(
481            "limnifs-delta-test-{}-parent-sub",
482            std::process::id()
483        ));
484        let child_dir = std::env::temp_dir().join(format!(
485            "limnifs-delta-test-{}-child-sub",
486            std::process::id()
487        ));
488        std::fs::create_dir_all(parent_dir.join("sub")).expect("create parent/sub");
489        std::fs::create_dir_all(child_dir.join("sub")).expect("create child/sub");
490        std::fs::write(parent_dir.join("sub").join("a.txt"), b"a").expect("write a");
491        std::fs::write(parent_dir.join("root.txt"), b"root").expect("write root");
492        std::fs::write(child_dir.join("sub").join("a.txt"), b"a").expect("copy a");
493        std::fs::write(child_dir.join("sub").join("b.txt"), b"b").expect("write b");
494        std::fs::write(child_dir.join("root.txt"), b"root").expect("copy root");
495
496        let parent_bytes = write_image(&parent_dir).expect("parent");
497        let child_bytes = write_image(&child_dir).expect("child");
498        std::fs::remove_dir_all(&parent_dir).ok();
499        std::fs::remove_dir_all(&child_dir).ok();
500
501        let delta = compute_delta_from_bytes(&parent_bytes, &child_bytes).expect("delta");
502        let add_paths: Vec<&str> = delta
503            .tree_ops
504            .iter()
505            .filter(|op| op.kind == TreeOpKind::Add)
506            .map(|op| op.path.as_str())
507            .collect();
508        assert!(
509            add_paths.contains(&"sub/b.txt"),
510            "expected sub/b.txt in adds, got {add_paths:?}"
511        );
512    }
513}