1#![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#[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#[derive(Clone, Debug)]
82pub struct DeltaArtifact {
83 pub tree_ops: Vec<TreeOp>,
84 pub base_root: [u8; 32],
85}
86
87impl DeltaArtifact {
88 #[must_use]
96 pub fn to_section_bytes(&self) -> Vec<u8> {
97 let mut bytes = Vec::new();
98 bytes.push(1u8); 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
116pub 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
129pub 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
172fn 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 limnifs_core::hash_section(bytes).clone_into(&mut merkle_root);
205
206 Ok((blob, root_number, merkle_root))
207}
208
209fn 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 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 ops.push(TreeOp {
257 kind: TreeOpKind::Add,
258 path: child_path,
259 inode_number: Some(*child_inum),
260 });
261 }
262 Some(parent_inum) => {
263 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 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
302fn 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 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}