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