1#![forbid(unsafe_code)]
21#![warn(clippy::pedantic)]
22
23pub mod chunker;
24pub mod classifier;
25pub mod compaction;
26pub mod delta_builder;
27pub mod flatten;
28pub mod turnover;
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use crate::chunker::FastCDC;
34use limnifs_core::{
35 compute_merkle_root, hash_empty_section, hash_section, ManifestHeader, SectionHashes,
36 FEATURE_FLAGS_SECTION_VERSION, HISTORY_SECTION_VERSION, METADATA_REFERENCE_SECTION_VERSION,
37 SLAB_INDEX_SECTION_VERSION,
38};
39use limnifs_format::{ManifestRoot, SlabId};
40
41pub const INLINE_THRESHOLD: usize = 4096;
44
45#[derive(Clone, Debug)]
47pub struct WriteArtifact {
48 pub bytes: Vec<u8>,
49 pub merkle_root: ManifestRoot,
50 pub slab_bytes: Option<Vec<u8>>,
51 pub slab_locator: Option<String>,
52 pub inode_count: usize,
53 pub file_count: usize,
54 pub dir_count: usize,
55 pub drop_count: usize,
56 pub root_inode_number: u64,
61}
62
63#[derive(Debug)]
65pub enum WriteError {
66 Io(std::io::Error),
67}
68
69impl std::fmt::Display for WriteError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::Io(e) => write!(f, "I/O error: {e}"),
73 }
74 }
75}
76
77impl std::error::Error for WriteError {}
78
79impl From<std::io::Error> for WriteError {
80 fn from(e: std::io::Error) -> Self {
81 Self::Io(e)
82 }
83}
84
85pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
99 use rayon::prelude::*;
100
101 let mut ctx = WriteContext::new();
102
103 let root_inode_number = ctx.walk(root)?;
107 ctx.root_inode_number = root_inode_number;
108
109 let pending = std::mem::take(&mut ctx.pending_files);
114 if !pending.is_empty() {
115 let chunker = ctx.chunker.clone();
116 let classifier = ctx.classifier;
117 let results: Vec<ChunkedFileResult> = pending
118 .par_iter()
119 .map(|pf| process_file(pf, &chunker, classifier))
120 .collect::<Result<Vec<_>, _>>()?;
121
122 for (pf, result) in pending.iter().zip(results) {
125 ctx.merge_chunked_file(pf, result);
126 }
127 }
128
129 let artifact = ctx.assemble();
130 Ok(artifact)
131}
132
133type RawDrop = ([u8; 32], Vec<u8>, Vec<u8>, u8);
135struct ChunkedFileResult {
137 drops: Vec<RawDrop>, slices: Vec<PendingSlice>,
139}
140
141fn process_file(
144 pf: &PendingFile,
145 chunker: &FastCDC,
146 classifier: classifier::Classifier,
147) -> Result<ChunkedFileResult, WriteError> {
148 let data = std::fs::read(&pf.path)?;
149 let file_len = data.len();
150 let chunks = chunker.chunk_slice(&data);
151 let mut drops = Vec::with_capacity(chunks.len());
152 let mut slices = Vec::with_capacity(chunks.len());
153 let mut file_offset: u64 = 0;
154
155 for chunk in chunks {
156 let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
157 let drop_id = hash_section(chunk);
158
159 let class = classifier.classify(chunk);
160 let codec_id = match class {
161 classifier::Class::Binary => limnifs_core::codec::best_binary_codec(),
162 classifier::Class::Text | classifier::Class::Code => {
163 limnifs_core::codec::best_compressible_codec()
164 }
165 _ => limnifs_core::codec::CODEC_STORE,
166 };
167 let compressed = if codec_id == limnifs_core::codec::CODEC_STORE {
168 chunk.to_vec()
169 } else {
170 limnifs_core::codec::compress(codec_id, chunk).unwrap_or_else(|_| chunk.to_vec())
171 };
172
173 drops.push((drop_id, chunk.to_vec(), compressed, codec_id));
174 slices.push(PendingSlice {
175 drop_id,
176 file_byte_start: file_offset,
177 file_byte_end: file_offset + chunk_len,
178 });
179 file_offset += chunk_len;
180 }
181 let _ = file_len;
182 Ok(ChunkedFileResult { drops, slices })
183}
184
185struct PendingDrop {
186 id: [u8; 32],
187 plaintext: Vec<u8>,
188 compressed: Vec<u8>,
189 codec: u8,
190 offset_in_window: u32,
191}
192
193impl PendingDrop {
194 fn len_in_window(&self) -> u32 {
198 u32::try_from(self.compressed.len()).expect("compressed fits u32")
199 }
200
201 fn plaintext_len(&self) -> u32 {
203 u32::try_from(self.plaintext.len()).expect("plaintext fits u32")
204 }
205}
206
207struct PendingSlice {
212 drop_id: [u8; 32],
213 file_byte_start: u64,
214 file_byte_end: u64,
215}
216
217struct PendingFile {
220 inode_number: u64,
221 path: PathBuf,
222 mtime_ns: u64,
223 file_len: u64,
224}
225
226struct PendingInode {
227 number: u64,
228 mode: u32,
229 mtime_ns: u64,
230 content: PendingContent,
231}
232
233enum PendingContent {
234 Inline(Vec<u8>),
235 DropBacked {
236 file_len: u64,
237 slices: Vec<PendingSlice>,
238 },
239 Directory(Vec<(String, u64, u8)>),
240}
241
242struct DirNode {
243 entries: Vec<(String, u64, u8)>,
244 bytes: Vec<u8>,
245 hash: [u8; 32],
246}
247
248struct WriteContext {
249 next_inode: u64,
250 inodes: Vec<PendingInode>,
251 dir_nodes: Vec<DirNode>,
252 drops: Vec<PendingDrop>,
253 drop_index: HashMap<[u8; 32], (u32, u32)>,
254 pending_files: Vec<PendingFile>,
255 file_count: usize,
256 dir_count: usize,
257 root_inode_number: u64,
258 chunker: FastCDC,
259 classifier: classifier::Classifier,
260}
261
262impl WriteContext {
263 fn new() -> Self {
264 Self {
265 next_inode: 1,
266 inodes: Vec::new(),
267 dir_nodes: Vec::new(),
268 drops: Vec::new(),
269 drop_index: HashMap::new(),
270 pending_files: Vec::new(),
271 file_count: 0,
272 dir_count: 0,
273 root_inode_number: 0,
274 chunker: FastCDC::default(),
275 classifier: classifier::Classifier,
276 }
277 }
278
279 fn alloc_inode(&mut self) -> u64 {
280 let n = self.next_inode;
281 self.next_inode += 1;
282 n
283 }
284
285 fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
288 for (drop_id, plaintext, compressed, codec) in result.drops {
289 if !self.drop_index.contains_key(&drop_id) {
290 let offset = self
291 .drops
292 .iter()
293 .map(PendingDrop::len_in_window)
294 .sum::<u32>();
295 let len = u32::try_from(compressed.len()).unwrap_or(0);
296 self.drops.push(PendingDrop {
297 id: drop_id,
298 plaintext,
299 compressed,
300 codec,
301 offset_in_window: offset,
302 });
303 self.drop_index.insert(drop_id, (offset, len));
304 }
305 }
306 self.inodes.push(PendingInode {
307 number: pf.inode_number,
308 mode: 0o100_644,
309 mtime_ns: pf.mtime_ns,
310 content: PendingContent::DropBacked {
311 file_len: pf.file_len,
312 slices: result.slices,
313 },
314 });
315 }
316
317 #[allow(dead_code)]
328 fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
329 let class = self.classifier.classify(plaintext);
330 let (codec, compressed) = match class {
331 classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
332 let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
333 (limnifs_core::codec::CODEC_LZ4, c)
334 }
335 _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec()),
336 };
337 PendingDrop {
338 id: drop_id,
339 plaintext: plaintext.to_vec(),
340 compressed,
341 codec,
342 offset_in_window: 0,
343 }
344 }
345
346 fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
347 let meta = std::fs::symlink_metadata(path)?;
348 let file_type = meta.file_type();
349 let mtime_ns = meta
350 .modified()
351 .ok()
352 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
353 .map_or(0u128, |d| d.as_nanos());
354 let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
355
356 if file_type.is_dir() {
357 self.dir_count += 1;
358 let inode_number = self.alloc_inode();
359 let mut entries: Vec<(String, u64, u8)> = Vec::new();
360
361 for entry in std::fs::read_dir(path)? {
362 let entry = entry?;
363 let name = entry.file_name().to_string_lossy().into_owned();
364 let child_path = entry.path();
365 let child_inode = self.walk(&child_path)?;
366 let child_meta = entry.metadata()?;
367 let entry_type = if child_meta.is_dir() { 0x02 } else { 0x01 };
368 entries.push((name, child_inode, entry_type));
369 }
370
371 entries.sort_by(|a, b| a.0.cmp(&b.0));
372 let dir_node = encode_dir_node(&entries);
373 self.dir_nodes.push(dir_node);
374 self.inodes.push(PendingInode {
375 number: inode_number,
376 mode: 0o040_755,
377 mtime_ns,
378 content: PendingContent::Directory(entries),
379 });
380 Ok(inode_number)
381 } else if file_type.is_file() {
382 self.file_count += 1;
383 let inode_number = self.alloc_inode();
384 let file_len = meta.len();
385
386 if file_len <= u64::try_from(INLINE_THRESHOLD).unwrap_or(u64::MAX) {
387 let data = std::fs::read(path)?;
388 self.inodes.push(PendingInode {
389 number: inode_number,
390 mode: 0o100_644,
391 mtime_ns,
392 content: PendingContent::Inline(data),
393 });
394 } else {
395 self.pending_files.push(PendingFile {
397 inode_number,
398 path: path.to_path_buf(),
399 mtime_ns,
400 file_len,
401 });
402 }
403 Ok(inode_number)
404 } else {
405 Err(WriteError::Io(std::io::Error::new(
406 std::io::ErrorKind::Unsupported,
407 format!("unsupported file type: {}", path.display()),
408 )))
409 }
410 }
411
412 fn assemble(self) -> WriteArtifact {
413 let inode_count = self.inodes.len();
414 let dir_count = self.dir_count;
415 let drop_count = self.drops.len();
416
417 let (slab_bytes, slab_id, slab_locator) = if self.drops.is_empty() {
418 (None, None, None)
419 } else {
420 let (bytes, id) = encode_slab(&self.drops);
421 let locator = "file:slab-0.bin".to_owned();
422 (Some(bytes), Some(id), Some(locator))
423 };
424
425 let mut metadata_blob = Vec::new();
426 metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
427 for inode in &self.inodes {
428 self.encode_inode(&mut metadata_blob, inode);
429 }
430 metadata_blob
431 .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
432 for node in &self.dir_nodes {
433 metadata_blob.extend_from_slice(&node.bytes);
434 }
435
436 let mut manifest = Vec::new();
437
438 let header_start = manifest.len();
439 manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
440 let header_end = manifest.len();
441
442 let flags_start = manifest.len();
443 manifest.push(FEATURE_FLAGS_SECTION_VERSION);
444 manifest.extend_from_slice(&0u32.to_le_bytes());
445 let flags_end = manifest.len();
446
447 let meta_ref_start = manifest.len();
448 manifest.push(METADATA_REFERENCE_SECTION_VERSION);
449 let metadata_hash = hash_section(&metadata_blob);
450 manifest.extend_from_slice(&metadata_hash);
451 manifest.extend_from_slice(&0u32.to_le_bytes());
452 let inline_len = u32::try_from(metadata_blob.len()).expect("metadata fits u32");
453 manifest.extend_from_slice(&inline_len.to_le_bytes());
454 manifest.extend_from_slice(&metadata_blob);
455 let meta_ref_end = manifest.len();
456
457 let slab_index_start = manifest.len();
458 manifest.push(SLAB_INDEX_SECTION_VERSION);
459 if let (Some(id), Some(loc)) = (&slab_id, &slab_locator) {
460 manifest.extend_from_slice(&1u32.to_le_bytes());
461 manifest.extend_from_slice(&id.to_bytes());
462 manifest.extend_from_slice(&1u32.to_le_bytes());
463 let loc_bytes = loc.as_bytes();
464 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
465 manifest.extend_from_slice(&loc_len.to_le_bytes());
466 manifest.extend_from_slice(loc_bytes);
467 } else {
468 manifest.extend_from_slice(&0u32.to_le_bytes());
469 }
470 let slab_index_end = manifest.len();
471
472 let history_start = manifest.len();
473 manifest.push(HISTORY_SECTION_VERSION);
474 manifest.extend_from_slice(&1u32.to_le_bytes());
475 manifest.push(0x01);
476 manifest.extend_from_slice(&0u64.to_le_bytes());
477 manifest.extend_from_slice(&0u32.to_le_bytes());
478 manifest.extend_from_slice(&0u32.to_le_bytes());
479 let history_end = manifest.len();
480
481 let hashes = SectionHashes {
482 metadata: metadata_hash,
483 format_header: hash_section(&manifest[header_start..header_end]),
484 feature_flags: hash_section(&manifest[flags_start..flags_end]),
485 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
486 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
487 crypto_params: hash_empty_section(),
488 ec_params: hash_empty_section(),
489 dms_policy: hash_empty_section(),
490 delta_linkage: hash_empty_section(),
491 history: hash_section(&manifest[history_start..history_end]),
492 };
493 let merkle_root = compute_merkle_root(&hashes);
494
495 WriteArtifact {
496 bytes: manifest,
497 merkle_root,
498 slab_bytes,
499 slab_locator,
500 inode_count,
501 file_count: self.file_count,
502 dir_count,
503 drop_count,
504 root_inode_number: self.root_inode_number,
505 }
506 }
507
508 fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
509 out.extend_from_slice(&inode.number.to_le_bytes());
510 out.extend_from_slice(&inode.mode.to_le_bytes());
511 out.extend_from_slice(&0u32.to_le_bytes());
512 out.extend_from_slice(&0u32.to_le_bytes());
513 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
514 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
515 out.extend_from_slice(&1u32.to_le_bytes());
516 match &inode.content {
517 PendingContent::Inline(data) => {
518 out.push(0x04);
519 let len = u32::try_from(data.len()).expect("data fits u32");
520 out.extend_from_slice(&len.to_le_bytes());
521 out.extend_from_slice(data);
522 }
523 PendingContent::DropBacked { file_len, slices } => {
524 out.push(0x00);
525 let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
526 out.extend_from_slice(&slice_count.to_le_bytes());
527 for slice in slices {
528 out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
529 out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
530 out.extend_from_slice(&slice.drop_id);
531 out.extend_from_slice(&0u32.to_le_bytes());
533 let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
537 .expect("slice range fits u32");
538 out.extend_from_slice(&drop_byte_len.to_le_bytes());
539 }
540 let _ = file_len;
541 }
542 PendingContent::Directory(entries) => {
543 out.push(0x00);
544 let node = self
545 .dir_nodes
546 .iter()
547 .find(|n| n.entries == *entries)
548 .expect("directory node must exist");
549 out.extend_from_slice(&node.hash);
550 }
551 }
552 }
553}
554
555fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
556 let mut bytes = Vec::new();
557 bytes.push(1u8);
558 let count = u32::try_from(entries.len()).expect("entry count fits u32");
559 bytes.extend_from_slice(&count.to_le_bytes());
560 for (name, inode_number, entry_type) in entries {
561 let name_bytes = name.as_bytes();
562 let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
563 bytes.extend_from_slice(&name_len.to_le_bytes());
564 bytes.extend_from_slice(name_bytes);
565 bytes.extend_from_slice(&inode_number.to_le_bytes());
566 bytes.push(*entry_type);
567 }
568 let hash = hash_section(&bytes);
569 DirNode {
570 entries: entries.to_vec(),
571 bytes,
572 hash,
573 }
574}
575
576fn encode_slab(drops: &[PendingDrop]) -> (Vec<u8>, SlabId) {
577 let mut drop_records = Vec::new();
578 let mut solid_window = Vec::new();
579
580 for drop in drops {
581 let plaintext_len = drop.plaintext_len();
582 let window_len = drop.len_in_window();
583 drop_records.extend_from_slice(&drop.id);
584 drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
585 drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
587 drop_records.push(0x00); drop_records.extend_from_slice(&drop.offset_in_window.to_le_bytes());
589 drop_records.extend_from_slice(&window_len.to_le_bytes());
590 solid_window.extend_from_slice(&drop.compressed);
591 }
592
593 let slab_content = [&drop_records[..], &solid_window[..]].concat();
594 let slab_hash = hash_section(&slab_content);
595 let slab_id = SlabId::new(0, slab_hash);
596
597 let total_length = 56 + slab_content.len();
598 let mut slab_bytes = Vec::with_capacity(total_length);
599 slab_bytes.extend_from_slice(b"LIM1");
600 slab_bytes.extend_from_slice(&1u16.to_le_bytes());
601 slab_bytes.extend_from_slice(&slab_id.to_bytes());
602 slab_bytes.extend_from_slice(&(total_length as u64).to_le_bytes());
603 slab_bytes.push(0x00);
604 slab_bytes.push(0x00);
605 slab_bytes.extend_from_slice(&slab_content);
606
607 (slab_bytes, slab_id)
608}
609
610#[cfg(test)]
611fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
612 let mut state = seed;
613 let mut out = Vec::with_capacity(count);
614 for _ in 0..count {
615 state = state
616 .wrapping_mul(6_364_136_223_846_793_005)
617 .wrapping_add(1_442_695_040_888_963_407);
618 out.push(u8::try_from(state >> 56).expect("fits u8"));
619 }
620 out
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use limnifs_core::ManifestCursor;
627
628 #[test]
629 fn write_empty_directory() {
630 let temp =
631 std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
632 std::fs::create_dir_all(&temp).expect("create temp dir");
633 let artifact = write_directory(&temp).expect("write succeeds");
634 std::fs::remove_dir_all(&temp).ok();
635 assert!(artifact.inode_count >= 1);
636 assert_eq!(artifact.file_count, 0);
637 assert_eq!(artifact.dir_count, 1);
638 assert!(artifact.slab_bytes.is_none());
639 }
640
641 #[test]
642 fn write_small_file_inline() {
643 let temp =
644 std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
645 std::fs::create_dir_all(&temp).expect("create temp dir");
646 std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
647 let artifact = write_directory(&temp).expect("write succeeds");
648 std::fs::remove_dir_all(&temp).ok();
649 assert_eq!(artifact.file_count, 1);
650 assert!(artifact.slab_bytes.is_none());
651 assert_eq!(artifact.drop_count, 0);
652 }
653
654 #[test]
655 fn write_large_file_uses_slab() {
656 let temp =
657 std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
658 std::fs::create_dir_all(&temp).expect("create temp dir");
659 let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
660 std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
661 let artifact = write_directory(&temp).expect("write succeeds");
662 std::fs::remove_dir_all(&temp).ok();
663 assert_eq!(artifact.drop_count, 1);
664 assert!(artifact.slab_bytes.is_some());
665 assert!(artifact.slab_locator.is_some());
666 }
667
668 #[test]
669 fn write_mixed_inline_and_large() {
670 let temp =
671 std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
672 std::fs::create_dir_all(&temp).expect("create temp dir");
673 std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
674 std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
675 .expect("write large");
676 let artifact = write_directory(&temp).expect("write succeeds");
677 std::fs::remove_dir_all(&temp).ok();
678 assert_eq!(artifact.file_count, 2);
679 assert_eq!(artifact.drop_count, 1);
680 assert!(artifact.slab_bytes.is_some());
681 }
682
683 #[test]
684 fn deduplicates_identical_large_files() {
685 let temp =
686 std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
687 std::fs::create_dir_all(&temp).expect("create temp dir");
688 let data = vec![0x77u8; INLINE_THRESHOLD + 10];
689 std::fs::write(temp.join("a.bin"), &data).expect("write a");
690 std::fs::write(temp.join("b.bin"), &data).expect("write b");
691 let artifact = write_directory(&temp).expect("write succeeds");
692 std::fs::remove_dir_all(&temp).ok();
693 assert_eq!(artifact.drop_count, 1);
694 }
695
696 #[test]
697 fn write_and_verify_roundtrip() {
698 let temp = std::env::temp_dir().join(format!(
699 "limnifs-write-test-{}-roundtrip",
700 std::process::id()
701 ));
702 std::fs::create_dir_all(&temp).expect("create temp dir");
703 std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
704 std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
705 std::fs::create_dir_all(temp.join("sub")).expect("create sub");
706 std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
707 let artifact = write_directory(&temp).expect("write succeeds");
708 std::fs::remove_dir_all(&temp).ok();
709 assert_eq!(artifact.file_count, 3);
710 assert_eq!(artifact.dir_count, 2);
711
712 let mut cursor = ManifestCursor::new(&artifact.bytes);
713 limnifs_core::parse_manifest_header(&mut cursor).expect("header");
714 limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
715 let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
716 assert!(meta_ref.is_inlined());
717 let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
718 assert_eq!(slab_index.len(), 0);
719 limnifs_core::parse_history(&mut cursor).expect("history");
720 }
721
722 #[test]
723 fn write_deterministic() {
724 let temp =
725 std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
726 std::fs::create_dir_all(&temp).expect("create temp dir");
727 std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
728
729 let a1 = write_directory(&temp).expect("first write");
730 let a2 = write_directory(&temp).expect("second write");
731 std::fs::remove_dir_all(&temp).ok();
732
733 assert_eq!(a1.bytes, a2.bytes);
734 assert_eq!(a1.merkle_root, a2.merkle_root);
735 }
736
737 #[test]
738 fn slab_parses_correctly() {
739 let temp =
740 std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
741 std::fs::create_dir_all(&temp).expect("create temp dir");
742 std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
743 .expect("write big");
744 let artifact = write_directory(&temp).expect("write succeeds");
745 std::fs::remove_dir_all(&temp).ok();
746
747 let slab_bytes = artifact.slab_bytes.as_ref().expect("slab exists");
748 let mut cursor = ManifestCursor::new(slab_bytes);
749 let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
750 assert_eq!(slab_header.format_version, 1);
751 assert!(!slab_header.is_sealed());
752 assert!(!slab_header.has_erasure_coding());
753
754 let drop_record =
755 limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
756 assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
757 }
758
759 #[test]
760 fn fastcdc_produces_multiple_chunks_for_large_files() {
761 let temp = std::env::temp_dir().join(format!(
764 "limnifs-write-test-{}-cdc-multi",
765 std::process::id()
766 ));
767 std::fs::create_dir_all(&temp).expect("create temp dir");
768 let data = pseudo_random_bytes(42, 1024 * 1024);
769 std::fs::write(temp.join("big.bin"), &data).expect("write big");
770 let artifact = write_directory(&temp).expect("write succeeds");
771 std::fs::remove_dir_all(&temp).ok();
772 assert!(
773 artifact.drop_count > 1,
774 "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
775 artifact.drop_count
776 );
777 }
778
779 #[test]
780 fn fastcdc_deduplicates_shared_substrings() {
781 let temp = std::env::temp_dir().join(format!(
785 "limnifs-write-test-{}-cdc-dedup",
786 std::process::id()
787 ));
788 std::fs::create_dir_all(&temp).expect("create temp dir");
789 let shared = pseudo_random_bytes(7, 512 * 1024);
790 let mut a = Vec::with_capacity(shared.len() + 1024);
791 a.extend_from_slice(&pseudo_random_bytes(1, 1024));
792 a.extend_from_slice(&shared);
793 let mut b = Vec::with_capacity(shared.len() + 2048);
794 b.extend_from_slice(&pseudo_random_bytes(2, 2048));
795 b.extend_from_slice(&shared);
796 std::fs::write(temp.join("a.bin"), &a).expect("write a");
797 std::fs::write(temp.join("b.bin"), &b).expect("write b");
798
799 let temp_a = std::env::temp_dir().join(format!(
801 "limnifs-write-test-{}-cdc-dedup-a",
802 std::process::id()
803 ));
804 std::fs::create_dir_all(&temp_a).expect("create temp_a");
805 std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
806 let artifact_a = write_directory(&temp_a).expect("a writes");
807 std::fs::remove_dir_all(&temp_a).ok();
808
809 let temp_b = std::env::temp_dir().join(format!(
810 "limnifs-write-test-{}-cdc-dedup-b",
811 std::process::id()
812 ));
813 std::fs::create_dir_all(&temp_b).expect("create temp_b");
814 std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
815 let artifact_b = write_directory(&temp_b).expect("b writes");
816 std::fs::remove_dir_all(&temp_b).ok();
817
818 let artifact_both = write_directory(&temp).expect("both write");
819 std::fs::remove_dir_all(&temp).ok();
820
821 let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
822 assert!(
823 artifact_both.drop_count < sum_alone,
824 "expected dedup win: both together = {} drops, sum alone = {} drops",
825 artifact_both.drop_count,
826 sum_alone
827 );
828 }
829}