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