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 }
73 }
74}
75
76#[derive(Clone, Debug)]
79pub struct DeltaArtifact {
80 pub tree_ops: Vec<TreeOp>,
81 pub base_root: [u8; 32],
82}
83
84impl DeltaArtifact {
85 #[must_use]
93 pub fn to_section_bytes(&self) -> Vec<u8> {
94 let mut bytes = Vec::new();
95 bytes.push(1u8); 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
113pub 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
126pub 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
169fn 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 limnifs_core::hash_section(bytes).clone_into(&mut merkle_root);
202
203 Ok((blob, root_number, merkle_root))
204}
205
206fn 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 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 ops.push(TreeOp {
254 kind: TreeOpKind::Add,
255 path: child_path,
256 inode_number: Some(*child_inum),
257 });
258 }
259 Some(parent_inum) => {
260 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 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
299fn 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 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}