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