1#![deny(unsafe_code)]
21#![allow(warnings)]
22
23pub mod chunker;
24pub mod classifier;
25pub mod compaction;
26pub mod config;
27pub mod delta_builder;
28pub mod dictionary;
29pub mod file_categorizer;
30pub mod flatten;
31#[cfg(feature = "pipeline-parallelism")]
32pub mod pipeline;
33pub mod rw;
34#[cfg(feature = "sparse-index")]
35pub mod sparse_index;
36pub mod turnover;
37
38pub use config::{
39 profile, CategorizerConfig, ChunkingConfig, CodecRegistry, CodecTunables, Defaults,
40 DictionaryConfig, EncryptionConfig, TournamentConfig, WriteConfig,
41};
42
43use std::collections::{HashMap, HashSet};
44use std::path::{Path, PathBuf};
45
46use crate::chunker::FastCDC;
47use limnifs_core::codec::CODEC_REFERENCED;
48use limnifs_core::slab_store::SlabStore;
49use limnifs_core::{
50 compute_merkle_root, hash_empty_section, hash_section, parse_manifest_header, parse_slab_index,
51 ManifestCursor, ManifestHeader, SectionHashes, FEATURE_FLAGS_SECTION_VERSION,
52 HISTORY_SECTION_VERSION, INODE_FLAG_INLINE_DATA, INODE_FLAG_SHARED_INLINE,
53 METADATA_REFERENCE_SECTION_VERSION_2, SLAB_INDEX_SECTION_VERSION,
54};
55use limnifs_format::{ManifestRoot, SlabId};
56
57pub const INLINE_THRESHOLD: usize = 4096;
60
61pub const MMAP_READ_THRESHOLD: usize = 1024 * 1024;
67
68pub const WHOLE_FILE_MAX_SIZE: usize = 64 * 1024 * 1024;
73
74pub const MAX_SLAB_TOTAL_BYTES: usize = 60 * 1024 * 1024;
79
80const SLAB_HEADER_LEN: usize = 56;
84
85pub const METADATA_EXTERNALIZE_THRESHOLD: usize = 768 * 1024;
91
92pub const METADATA_LARGE_BLOB_THRESHOLD: usize = 256 * 1024;
97
98pub const METADATA_SMALL_BLOB_QUALITY: i32 = 5;
101
102pub const METADATA_LARGE_BLOB_QUALITY: i32 = 2;
107
108#[derive(Clone, Debug)]
111pub struct SlabArtifact {
112 pub id: SlabId,
113 pub bytes: Vec<u8>,
114 pub locator: String,
115 pub drop_ids: Vec<[u8; 32]>,
119}
120
121#[derive(Clone, Debug)]
125pub struct MetadataSidecar {
126 pub bytes: Vec<u8>,
127 pub locator: String,
128}
129
130#[derive(Clone, Debug)]
132pub struct WriteArtifact {
133 pub bytes: Vec<u8>,
134 pub merkle_root: ManifestRoot,
135 pub slabs: Vec<SlabArtifact>,
138 pub metadata_sidecar: Option<MetadataSidecar>,
142 pub inode_count: usize,
143 pub file_count: usize,
144 pub dir_count: usize,
145 pub drop_count: usize,
146 pub root_inode_number: u64,
151}
152
153impl WriteArtifact {
154 #[must_use]
158 pub fn slab_bytes(&self) -> Option<&[u8]> {
159 if self.slabs.len() == 1 {
160 Some(&self.slabs[0].bytes)
161 } else {
162 None
163 }
164 }
165
166 #[must_use]
168 pub fn slab_locator(&self) -> Option<&str> {
169 if self.slabs.len() == 1 {
170 Some(&self.slabs[0].locator)
171 } else {
172 None
173 }
174 }
175}
176
177#[derive(Debug)]
179pub enum WriteError {
180 Io(std::io::Error),
181}
182
183impl std::fmt::Display for WriteError {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 match self {
186 Self::Io(e) => write!(f, "I/O error: {e}"),
187 }
188 }
189}
190
191impl std::error::Error for WriteError {}
192
193impl From<std::io::Error> for WriteError {
194 fn from(e: std::io::Error) -> Self {
195 Self::Io(e)
196 }
197}
198
199pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
213 write_directory_with_config(root, &WriteConfig::default_v0_1())
214}
215
216pub fn write_stream<R: std::io::Read>(
232 name: &str,
233 reader: R,
234 config: &WriteConfig,
235) -> Result<WriteArtifact, WriteError> {
236 let mut ctx = WriteContext::new();
237 ctx.categorizers_disabled = config.categorizers.is_empty();
238 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
239 ctx.auto_turnover = config.turnover_threshold > 0;
240 ctx.collect_dict_samples = config.dictionaries.enabled;
241
242 let drop_id_root = [0u8; 32]; let pending = PendingFile {
247 path: std::path::PathBuf::from(name),
248 inode_number: 1,
249 file_len: 0, mtime_ns: 0,
251 };
252 ctx.pending_files.push(pending);
253 ctx.root_inode_number = 1;
254
255 let chunker = ctx.chunker.clone();
257 let chunks = chunker.chunk_reader(reader)?;
258
259 let total_len: u64 = chunks.iter().map(|c| c.len() as u64).sum();
261
262 let text_codec = config.text_codec_id().unwrap_or(0x04);
266 let binary_codec = config.binary_codec_id().unwrap_or(0x01);
267 let tunables = config.to_core_tunables();
268 let classifier = ctx.classifier;
269 let registry = config
270 .codec_registry()
271 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
272 let tournament_codec_ids: Vec<u8> = config
273 .tournament
274 .codecs
275 .iter()
276 .filter_map(|n| registry.lookup_by_name(n))
277 .collect();
278 let tournament = TournamentSpec {
279 codec_ids: tournament_codec_ids,
280 min_size: config.tournament.min_size_threshold as usize,
281 skip_for_binary: config.tournament.skip_for_binary,
282 short_circuit_permille: config.tournament.short_circuit_threshold,
283 };
284
285 let mut drops: Vec<RawDrop> = Vec::with_capacity(chunks.len());
286 let mut slices: Vec<PendingSlice> = Vec::with_capacity(chunks.len());
287 let mut offset: u64 = 0;
288 for chunk in &chunks {
289 let drop_id = hash_section(chunk);
290 slices.push(PendingSlice {
291 drop_id,
292 file_byte_start: offset,
293 file_byte_end: offset + chunk.len() as u64,
294 });
295 offset += chunk.len() as u64;
296 let class = classifier.classify(chunk);
297 let (codec_id, compressed) = compress_chunk_with_tournament(
298 chunk,
299 class,
300 text_codec,
301 binary_codec,
302 &tunables,
303 &tournament,
304 );
305 drops.push((drop_id, chunk.clone(), compressed, codec_id));
306 }
307 let _ = drop_id_root;
308
309 let result = ChunkedFileResult { drops, slices };
311 let pf = ctx.pending_files[0].clone();
312 ctx.merge_chunked_file(&pf, result);
313 ctx.pending_files[0].file_len = total_len;
315 if let Some(inode) = ctx.inodes.last_mut() {
318 if let PendingContent::DropBacked { file_len, .. } = &mut inode.content {
319 *file_len = total_len;
320 }
321 }
322
323 ctx.train_and_apply_dictionary(&config.dictionaries);
324 let artifact = ctx.assemble();
325 Ok(artifact)
326}
327
328pub fn write_layer(
369 base_image: &Path,
370 root: &Path,
371 config: &WriteConfig,
372) -> Result<WriteArtifact, WriteError> {
373 let (base_drop_index, base_root) = load_base_drop_index(base_image)?;
375
376 let mut ctx = WriteContext::new();
377 ctx.categorizers_disabled = config.categorizers.is_empty();
378 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
379 ctx.auto_turnover = config.turnover_threshold > 0;
380 ctx.collect_dict_samples = config.dictionaries.enabled;
381 ctx.inline_threshold = config.defaults.inline_threshold as usize;
382 ctx.base_drop_index = Some(base_drop_index);
383 ctx.base_root = Some(base_root);
384
385 let root_inode_number = ctx.walk(root)?;
387 ctx.root_inode_number = root_inode_number;
388 write_directory_body(&mut ctx, config)?;
389 Ok(ctx.assemble())
390}
391
392fn load_base_drop_index(
396 base_image: &Path,
397) -> Result<(std::collections::HashSet<[u8; 32]>, [u8; 32]), WriteError> {
398 let manifest_bytes = std::fs::read(base_image)?;
399 let mut cursor = ManifestCursor::new(&manifest_bytes);
400 let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
401 let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
403 let _ = limnifs_core::parse_metadata_reference(&mut cursor);
404 let slab_index = parse_slab_index(&mut cursor).map_err(io_core)?;
405 let store = SlabStore::load_mmap(base_image, &slab_index).map_err(io_core)?;
406 let drop_set: std::collections::HashSet<[u8; 32]> = store.drop_index_keys().copied().collect();
407 let root = *compute_merkle_root_from_sections(&manifest_bytes).as_bytes();
413 Ok((drop_set, root))
414}
415
416fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
422 use limnifs_core::SectionHashes;
423 let mut cursor = ManifestCursor::new(manifest);
424 let header_start = 0;
425 if parse_manifest_header(&mut cursor).is_err() {
426 return ManifestRoot::from_bytes([0u8; 32]);
428 }
429 let header_end = cursor.position();
430 let flags_start = header_end;
432 let flags_end = match limnifs_core::parse_feature_flags_section(&mut cursor) {
433 Ok(_) => cursor.position(),
434 Err(_) => flags_start,
435 };
436 let meta_ref_start = flags_end;
437 let metadata_reference = match limnifs_core::parse_metadata_reference(&mut cursor) {
438 Ok(m) => Some(m),
439 Err(_) => None,
440 };
441 let meta_ref_end = cursor.position();
442 let slab_index_start = meta_ref_end;
443 let _ = parse_slab_index(&mut cursor);
444 let slab_index_end = cursor.position();
445 let history_start = slab_index_end;
446 let _ = limnifs_core::parse_history(&mut cursor);
447 let history_end = cursor.position();
448
449 let hashes = SectionHashes {
450 metadata: metadata_reference
451 .map(|m| m.metadata_hash)
452 .unwrap_or_else(hash_empty_section),
453 format_header: hash_section(&manifest[header_start..header_end]),
454 feature_flags: hash_section(&manifest[flags_start..flags_end]),
455 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
456 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
457 crypto_params: hash_empty_section(),
458 ec_params: hash_empty_section(),
459 dms_policy: hash_empty_section(),
460 delta_linkage: hash_empty_section(),
461 history: hash_section(&manifest[history_start..history_end]),
462 };
463 compute_merkle_root(&hashes)
464}
465
466fn io_core(e: limnifs_core::CoreError) -> WriteError {
467 WriteError::Io(std::io::Error::other(format!("base image load: {e}")))
468}
469
470fn write_directory_body(ctx: &mut WriteContext, config: &WriteConfig) -> Result<(), WriteError> {
475 use rayon::prelude::*;
476
477 ctx.metadata_codec = config
478 .metadata_codec_id()
479 .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
480
481 let pending = std::mem::take(&mut ctx.pending_files);
482 if pending.is_empty() {
483 return Ok(());
484 }
485 ctx.inline_threshold = config.defaults.inline_threshold as usize;
486 let chunker = ctx.chunker.clone();
487 let classifier = ctx.classifier;
488 let text_codec = config.text_codec_id().unwrap_or(0x04);
489 let binary_codec = config.binary_codec_id().unwrap_or(0x01);
490 let tunables = config.to_core_tunables();
491 let use_categorizers = !config.categorizers.is_empty();
492 let skip_chunking = config.skip_chunking;
493 let registry = config
494 .codec_registry()
495 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
496 let tournament_codec_ids: Vec<u8> = config
497 .tournament
498 .codecs
499 .iter()
500 .filter_map(|n| registry.lookup_by_name(n))
501 .collect();
502 let tournament_spec = TournamentSpec {
503 codec_ids: tournament_codec_ids,
504 min_size: config.tournament.min_size_threshold as usize,
505 skip_for_binary: config.tournament.skip_for_binary,
506 short_circuit_permille: config.tournament.short_circuit_threshold,
507 };
508 let base_drop_index = ctx.base_drop_index.as_ref();
509 let inline_threshold = ctx.inline_threshold;
510 let results: Vec<ChunkedFileResult> = pending
511 .par_iter()
512 .map(|pf| {
513 process_file(
514 pf,
515 &chunker,
516 classifier,
517 text_codec,
518 binary_codec,
519 &tunables,
520 use_categorizers,
521 skip_chunking,
522 &tournament_spec,
523 base_drop_index,
524 inline_threshold,
525 )
526 })
527 .collect::<Result<Vec<_>, _>>()?;
528
529 for (pf, result) in pending.iter().zip(results) {
530 ctx.merge_chunked_file(pf, result);
531 }
532 ctx.train_and_apply_dictionary(&config.dictionaries);
533 Ok(())
534}
535
536pub fn write_directory_with_config(
538 root: &Path,
539 config: &WriteConfig,
540) -> Result<WriteArtifact, WriteError> {
541 let mut ctx = WriteContext::new();
542 ctx.categorizers_disabled = config.categorizers.is_empty();
543 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
544 ctx.auto_turnover = config.turnover_threshold > 0;
545 ctx.collect_dict_samples = config.dictionaries.enabled;
546
547 write_directory_streaming(&mut ctx, root, config)?;
548 Ok(ctx.assemble())
549}
550
551fn write_directory_streaming(
565 ctx: &mut WriteContext,
566 root: &Path,
567 config: &WriteConfig,
568) -> Result<(), WriteError> {
569 use rayon::prelude::*;
570
571 ctx.metadata_codec = config
572 .metadata_codec_id()
573 .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
574
575 let chunker = ctx.chunker.clone();
576 let classifier = ctx.classifier;
577 let text_codec = config.text_codec_id().unwrap_or(0x04);
578 let binary_codec = config.binary_codec_id().unwrap_or(0x01);
579 let tunables = config.to_core_tunables();
580 let use_categorizers = !config.categorizers.is_empty();
581 let skip_chunking = config.skip_chunking;
582 let registry = config
583 .codec_registry()
584 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
585 let tournament_codec_ids: Vec<u8> = config
586 .tournament
587 .codecs
588 .iter()
589 .filter_map(|n| registry.lookup_by_name(n))
590 .collect();
591 let tournament_spec = TournamentSpec {
592 codec_ids: tournament_codec_ids,
593 min_size: config.tournament.min_size_threshold as usize,
594 skip_for_binary: config.tournament.skip_for_binary,
595 short_circuit_permille: config.tournament.short_circuit_threshold,
596 };
597 let base_drop_index = ctx.base_drop_index.clone();
600 let inline_threshold = ctx.inline_threshold;
601
602 ctx.inline_threshold = config.defaults.inline_threshold as usize;
603
604 const PIPELINE_CAPACITY: usize = 256;
608 let (tx, rx) = std::sync::mpsc::sync_channel::<PendingFile>(PIPELINE_CAPACITY);
609 ctx.pending_sink = Some(tx);
610
611 let (root_inode_number, mut results): (
612 u64,
613 Vec<(usize, PendingFile, Result<ChunkedFileResult, WriteError>)>,
614 ) = std::thread::scope(|scope| {
615 let producer = {
616 let ctx = &mut *ctx;
617 let root = root;
618 scope.spawn(move || {
619 let r = ctx.walk(root);
620 ctx.pending_sink = None;
624 r
625 })
626 };
627 let results = rx
630 .into_iter()
631 .enumerate()
632 .par_bridge()
633 .map(|(i, pf)| {
634 let r = process_file(
635 &pf,
636 &chunker,
637 classifier,
638 text_codec,
639 binary_codec,
640 &tunables,
641 use_categorizers,
642 skip_chunking,
643 &tournament_spec,
644 base_drop_index.as_ref(),
645 inline_threshold,
646 );
647 (i, pf, r)
648 })
649 .collect();
650 let joined = producer
651 .join()
652 .unwrap_or_else(|_| {
653 Err(WriteError::Io(std::io::Error::other(
654 "walk thread panicked",
655 )))
656 })
657 .map(|n| (n, results));
658 joined
661 })?;
662 ctx.pending_sink = None;
663 ctx.root_inode_number = root_inode_number;
664
665 results.sort_unstable_by_key(|(i, _, _)| *i);
666 for (_, pf, r) in results {
670 ctx.merge_chunked_file(&pf, r?);
671 }
672 ctx.train_and_apply_dictionary(&config.dictionaries);
673 Ok(())
674}
675
676pub(crate) type RawDrop = ([u8; 32], Vec<u8>, std::sync::Arc<[u8]>, u8);
683pub(crate) struct ChunkedFileResult {
685 drops: Vec<RawDrop>, slices: Vec<PendingSlice>,
687}
688
689struct TournamentSpec {
697 codec_ids: Vec<u8>,
701 min_size: usize,
705 skip_for_binary: bool,
709 short_circuit_permille: u32,
714}
715
716fn process_whole_file_drop(
729 pf: &PendingFile,
730 data: &[u8],
731 cat: file_categorizer::Categorization,
732 tunables: &limnifs_core::codec::CodecTunables,
733) -> Result<ChunkedFileResult, WriteError> {
734 let _ = pf;
735 let drop_id = hash_section(data);
736 let file_len = u64::try_from(data.len()).unwrap_or(u64::MAX);
737
738 let (mut best_codec, mut best_compressed): (u8, std::sync::Arc<[u8]>) =
743 match limnifs_core::codec::compress_with_tunables(
744 limnifs_core::codec::CODEC_BROTLI,
745 data,
746 tunables,
747 ) {
748 Ok(c) => (limnifs_core::codec::CODEC_BROTLI, c.into()),
749 Err(_) => match limnifs_core::codec::compress_with_tunables(
750 limnifs_core::codec::CODEC_ZSTD,
751 data,
752 tunables,
753 ) {
754 Ok(c) => (limnifs_core::codec::CODEC_ZSTD, c.into()),
755 Err(_) => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
756 },
757 };
758
759 let brotli_ratio = best_compressed.len() as f64 / data.len() as f64;
763 if brotli_ratio > 0.05 {
764 if let Ok(zstd_c) = limnifs_core::codec::compress_with_tunables(
765 limnifs_core::codec::CODEC_ZSTD,
766 data,
767 tunables,
768 ) {
769 if zstd_c.len() < best_compressed.len() {
770 best_codec = limnifs_core::codec::CODEC_ZSTD;
771 best_compressed = zstd_c.into();
772 }
773 }
774 }
775
776 let general_ratio = best_compressed.len() as f64 / data.len() as f64;
782 if general_ratio > 0.15 || cat.codec_id == limnifs_core::codec::CODEC_RICEPP {
783 let spec_result = if cat.codec_id == limnifs_core::codec::CODEC_FSST_BROTLI {
787 limnifs_core::codec::fsst_brotli::compress_with_baseline(data, Some(&best_compressed))
788 } else {
789 limnifs_core::codec::compress(cat.codec_id, data)
790 };
791 if let Ok(spec_c) = spec_result {
792 if spec_c.len() < best_compressed.len() {
793 best_codec = cat.codec_id;
794 best_compressed = spec_c.into();
795 }
796 }
797 }
798
799 Ok(ChunkedFileResult {
800 drops: vec![(drop_id, data.to_vec(), best_compressed, best_codec)],
801 slices: vec![PendingSlice {
802 drop_id,
803 file_byte_start: 0,
804 file_byte_end: file_len,
805 }],
806 })
807}
808
809fn compress_chunk_with_tournament(
831 chunk: &[u8],
832 class: classifier::Class,
833 text_codec: u8,
834 binary_codec: u8,
835 tunables: &limnifs_core::codec::CodecTunables,
836 tournament: &TournamentSpec,
837) -> (u8, std::sync::Arc<[u8]>) {
838 use classifier::Class;
839
840 let preferred = match class {
841 Class::Binary => binary_codec,
842 Class::Text | Class::Code | Class::Sparse => text_codec,
843 _ => limnifs_core::codec::CODEC_STORE,
844 };
845
846 if preferred == limnifs_core::codec::CODEC_STORE {
847 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
848 }
849 if class == Class::Binary && tournament.skip_for_binary {
850 return compress_chunk_one(chunk, preferred, tunables);
851 }
852 if chunk.len() < tournament.min_size {
853 return compress_chunk_one(chunk, preferred, tunables);
854 }
855
856 let mut best: Option<(u8, std::sync::Arc<[u8]>)> = None;
857 for &codec_id in &tournament.codec_ids {
858 if codec_id == limnifs_core::codec::CODEC_STORE {
859 continue;
860 }
861 let c = match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
862 Ok(c) => c,
863 Err(_) => continue,
864 };
865 if c.len() >= chunk.len() {
866 continue;
867 }
868 let ratio_permille = (c.len() as u64 * 1000 / chunk.len() as u64) as u32;
869 let is_best_so_far = best.as_ref().map_or(true, |(_, b)| c.len() < b.len());
870 if is_best_so_far {
871 best = Some((codec_id, c.into()));
872 }
873 if tournament.short_circuit_permille > 0
874 && ratio_permille <= tournament.short_circuit_permille
875 {
876 break;
877 }
878 }
879
880 best.unwrap_or_else(|| (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()))
881}
882
883fn compress_chunk_one(
886 chunk: &[u8],
887 codec_id: u8,
888 tunables: &limnifs_core::codec::CodecTunables,
889) -> (u8, std::sync::Arc<[u8]>) {
890 if codec_id == limnifs_core::codec::CODEC_STORE {
891 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
892 }
893 match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
894 Ok(c) if c.len() < chunk.len() => (codec_id, c.into()),
895 _ => (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()),
896 }
897}
898
899fn process_file(
903 pf: &PendingFile,
904 chunker: &FastCDC,
905 classifier: classifier::Classifier,
906 text_codec: u8,
907 binary_codec: u8,
908 tunables: &limnifs_core::codec::CodecTunables,
909 use_categorizers: bool,
910 skip_chunking: bool,
911 tournament: &TournamentSpec,
912 base_drop_index: Option<&std::collections::HashSet<[u8; 32]>>,
913 inline_threshold: usize,
914) -> Result<ChunkedFileResult, WriteError> {
915 let file_len_estimate = std::fs::metadata(&pf.path)
926 .map(|m| m.len() as usize)
927 .unwrap_or(0);
928 let data: Vec<u8> = if file_len_estimate >= MMAP_READ_THRESHOLD {
929 let file = std::fs::File::open(&pf.path)?;
930 #[allow(unsafe_code)]
931 let mmap = unsafe { memmap2::Mmap::map(&file) }.map_err(WriteError::Io)?;
932 Vec::from(&mmap[..])
936 } else {
937 std::fs::read(&pf.path)?
938 };
939 let file_len = data.len();
940
941 if skip_chunking && file_len > inline_threshold {
948 let drop_id = hash_section(&data);
949 let class = classifier.classify(&data);
950 let preferred_codec = match class {
951 classifier::Class::Binary => binary_codec,
952 _ => text_codec,
953 };
954 let (codec_id, compressed): (u8, std::sync::Arc<[u8]>) =
955 match limnifs_core::codec::compress_with_tunables(preferred_codec, &data, tunables) {
956 Ok(c) if c.len() < data.len() => (preferred_codec, c.into()),
957 _ => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
958 };
959 return Ok(ChunkedFileResult {
960 drops: vec![(drop_id, data, compressed, codec_id)],
961 slices: vec![PendingSlice {
962 drop_id,
963 file_byte_start: 0,
964 file_byte_end: file_len as u64,
965 }],
966 });
967 }
968
969 if use_categorizers {
970 if let Some(cat) = file_categorizer::default_registry().categorize(&pf.path, &data) {
971 let needs_whole_file = matches!(
972 cat.codec_id,
973 limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
974 );
975 if needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE {
976 return process_whole_file_drop(pf, &data, cat, tunables);
977 }
978 }
979 }
980
981 let chunks = chunker.chunk_slice(&data);
982 let mut slices = Vec::with_capacity(chunks.len());
983 let mut file_offset: u64 = 0;
984 let mut seen_in_file: std::collections::HashSet<[u8; 32]> =
985 std::collections::HashSet::with_capacity(chunks.len());
986
987 let mut unique_chunks: Vec<(&[u8], [u8; 32])> = Vec::with_capacity(chunks.len());
990 for chunk in &chunks {
991 let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
992 let drop_id = hash_section(chunk);
993 slices.push(PendingSlice {
994 drop_id,
995 file_byte_start: file_offset,
996 file_byte_end: file_offset + chunk_len,
997 });
998 file_offset += chunk_len;
999 if seen_in_file.insert(drop_id) {
1000 unique_chunks.push((chunk, drop_id));
1001 }
1002 }
1003
1004 use rayon::prelude::*;
1016 thread_local! {
1017 static COMPRESS_CACHE: std::cell::RefCell<std::collections::HashMap<[u8; 32], (u8, std::sync::Arc<[u8]>)>> =
1018 std::cell::RefCell::new(std::collections::HashMap::new());
1019 }
1020 const COMPRESS_CACHE_MAX_ENTRIES: usize = 100_000;
1021 let drops: Vec<RawDrop> = unique_chunks
1022 .par_iter()
1023 .map(|(chunk, drop_id)| {
1024 if let Some(base) = base_drop_index {
1027 if base.contains(drop_id) {
1028 return (*drop_id, Vec::new(), Vec::new().into(), CODEC_REFERENCED);
1029 }
1030 }
1031 let class = classifier.classify(chunk);
1032 let cached = COMPRESS_CACHE.with(|c| {
1035 c.borrow()
1036 .get(drop_id)
1037 .map(|(cid, comp)| (*cid, comp.clone()))
1038 });
1039 let (codec_id, compressed) = if let Some(c) = cached {
1040 c
1041 } else {
1042 let new = compress_chunk_with_tournament(
1043 chunk,
1044 class,
1045 text_codec,
1046 binary_codec,
1047 tunables,
1048 tournament,
1049 );
1050 COMPRESS_CACHE.with(|c| {
1052 let mut cache = c.borrow_mut();
1053 if cache.len() < COMPRESS_CACHE_MAX_ENTRIES {
1054 cache.insert(*drop_id, new.clone());
1056 }
1057 });
1058 new
1059 };
1060 (*drop_id, chunk.to_vec(), compressed, codec_id)
1061 })
1062 .collect();
1063
1064 let _ = file_len;
1065 Ok(ChunkedFileResult { drops, slices })
1066}
1067
1068struct PendingDrop {
1069 id: [u8; 32],
1070 plaintext_len: u32,
1076 compressed: std::sync::Arc<[u8]>,
1077 codec: u8,
1078 dict_id: u8,
1082 plaintext: Option<Vec<u8>>,
1087}
1088
1089impl PendingDrop {
1090 fn len_in_window(&self) -> u32 {
1094 u32::try_from(self.compressed.len()).expect("compressed fits u32")
1095 }
1096
1097 fn plaintext_len_value(&self) -> u32 {
1099 self.plaintext_len
1100 }
1101
1102 fn slab_footprint(&self) -> usize {
1105 48 + self.compressed.len()
1106 }
1107}
1108
1109struct PendingSlice {
1114 drop_id: [u8; 32],
1115 file_byte_start: u64,
1116 file_byte_end: u64,
1117}
1118
1119#[derive(Clone)]
1122struct PendingFile {
1123 inode_number: u64,
1124 path: PathBuf,
1125 mtime_ns: u64,
1126 file_len: u64,
1127}
1128
1129struct PendingInode {
1130 number: u64,
1131 mode: u32,
1132 mtime_ns: u64,
1133 content: PendingContent,
1134}
1135
1136enum PendingContent {
1137 Inline(Vec<u8>),
1138 DropBacked {
1139 file_len: u64,
1140 slices: Vec<PendingSlice>,
1141 },
1142 Directory(Vec<(String, u64, u8)>),
1143}
1144
1145struct DirNode {
1146 entries: Vec<(String, u64, u8)>,
1147 bytes: Vec<u8>,
1148 hash: [u8; 32],
1149}
1150
1151struct WriteContext {
1152 next_inode: u64,
1153 inodes: Vec<PendingInode>,
1154 dir_nodes: Vec<DirNode>,
1155 drops: Vec<PendingDrop>,
1156 drop_index: HashSet<[u8; 32]>,
1157 pending_files: Vec<PendingFile>,
1158 file_count: usize,
1159 dir_count: usize,
1160 root_inode_number: u64,
1161 chunker: FastCDC,
1162 classifier: classifier::Classifier,
1163 shared_inline_map: HashMap<[u8; 32], usize>,
1164 shared_inline_table: Vec<Vec<u8>>,
1165 profile_name: Option<String>,
1167 metadata_codec: u8,
1170 categorizers_disabled: bool,
1172 rw_mode: bool,
1174 auto_turnover: bool,
1176 collect_dict_samples: bool,
1179 dict_samples_by_class: HashMap<crate::classifier::Class, Vec<Vec<u8>>>,
1186 trained_dicts_by_class: HashMap<crate::classifier::Class, crate::dictionary::TrainedDictionary>,
1191 base_drop_index: Option<HashSet<[u8; 32]>>,
1197 base_root: Option<[u8; 32]>,
1202 inline_threshold: usize,
1207 pending_sink: Option<std::sync::mpsc::SyncSender<PendingFile>>,
1213}
1214
1215impl WriteContext {
1216 const MAX_DICT_SAMPLES: usize = 1000;
1219
1220 fn new() -> Self {
1221 Self {
1222 next_inode: 1,
1223 inodes: Vec::new(),
1224 dir_nodes: Vec::new(),
1225 drops: Vec::new(),
1226 drop_index: HashSet::new(),
1227 pending_files: Vec::new(),
1228 file_count: 0,
1229 dir_count: 0,
1230 root_inode_number: 0,
1231 chunker: FastCDC::default(),
1232 classifier: classifier::Classifier,
1233 shared_inline_map: HashMap::new(),
1234 shared_inline_table: Vec::new(),
1235 profile_name: None,
1236 metadata_codec: limnifs_core::codec::CODEC_BROTLI,
1237 categorizers_disabled: false,
1238 rw_mode: false,
1239 auto_turnover: false,
1240 collect_dict_samples: false,
1241 dict_samples_by_class: HashMap::new(),
1242 trained_dicts_by_class: HashMap::new(),
1243 base_drop_index: None,
1244 base_root: None,
1245 pending_sink: None,
1246 inline_threshold: INLINE_THRESHOLD,
1247 }
1248 }
1249
1250 fn alloc_inode(&mut self) -> u64 {
1251 let n = self.next_inode;
1252 self.next_inode += 1;
1253 n
1254 }
1255
1256 fn build_shared_inline_table(&mut self) {
1260 let mut counts: HashMap<[u8; 32], usize> = HashMap::new();
1261 for inode in &self.inodes {
1262 if let PendingContent::Inline(data) = &inode.content {
1263 let h = hash_section(data);
1264 *counts.entry(h).or_default() += 1;
1265 }
1266 }
1267 for inode in &self.inodes {
1269 if let PendingContent::Inline(data) = &inode.content {
1270 let h = hash_section(data);
1271 if counts.get(&h).copied().unwrap_or(0) > 1
1272 && !self.shared_inline_map.contains_key(&h)
1273 {
1274 let idx = self.shared_inline_table.len();
1275 self.shared_inline_table.push(data.clone());
1276 self.shared_inline_map.insert(h, idx);
1277 }
1278 }
1279 }
1280 }
1281
1282 fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
1285 for (drop_id, plaintext, compressed, codec) in result.drops {
1286 if self.drop_index.insert(drop_id) {
1287 let retain_plaintext =
1293 self.collect_dict_samples && codec == limnifs_core::codec::CODEC_ZSTD;
1294 if retain_plaintext {
1295 let total: usize = self.dict_samples_by_class.values().map(Vec::len).sum();
1296 if total < Self::MAX_DICT_SAMPLES {
1297 let class = self.classifier.classify(&plaintext);
1298 self.dict_samples_by_class
1299 .entry(class)
1300 .or_default()
1301 .push(plaintext.clone());
1302 }
1303 }
1304 self.drops.push(PendingDrop {
1305 id: drop_id,
1306 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1307 compressed,
1308 codec,
1309 dict_id: limnifs_core::drop_record::NO_DICT,
1310 plaintext: if retain_plaintext {
1311 Some(plaintext)
1312 } else {
1313 None
1314 },
1315 });
1316 }
1317 }
1318 self.inodes.push(PendingInode {
1319 number: pf.inode_number,
1320 mode: 0o100_644,
1321 mtime_ns: pf.mtime_ns,
1322 content: PendingContent::DropBacked {
1323 file_len: pf.file_len,
1324 slices: result.slices,
1325 },
1326 });
1327 }
1328
1329 #[allow(dead_code)]
1340 fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
1341 let class = self.classifier.classify(plaintext);
1342 let (codec, compressed): (u8, std::sync::Arc<[u8]>) = match class {
1343 classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
1344 let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
1345 (limnifs_core::codec::CODEC_LZ4, c.into())
1346 }
1347 _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec().into()),
1348 };
1349 PendingDrop {
1350 id: drop_id,
1351 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1352 compressed,
1353 codec,
1354 dict_id: limnifs_core::drop_record::NO_DICT,
1355 plaintext: None,
1356 }
1357 }
1358
1359 fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
1360 let meta = std::fs::symlink_metadata(path)?;
1361 let file_type = meta.file_type();
1362 let mtime_ns = meta
1363 .modified()
1364 .ok()
1365 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1366 .map_or(0u128, |d| d.as_nanos());
1367 let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
1368
1369 if file_type.is_dir() {
1370 self.dir_count += 1;
1371 let inode_number = self.alloc_inode();
1372 let mut entries: Vec<(String, u64, u8)> = Vec::new();
1373
1374 for entry in std::fs::read_dir(path)? {
1375 let entry = entry?;
1376 let name = entry.file_name().to_string_lossy().into_owned();
1377 let child_path = entry.path();
1378 let child_inode = self.walk(&child_path)?;
1379 let child_meta = entry.metadata()?;
1380 let entry_type = if child_meta.is_dir() { 0x02 } else { 0x01 };
1381 entries.push((name, child_inode, entry_type));
1382 }
1383
1384 entries.sort_by(|a, b| a.0.cmp(&b.0));
1385 let dir_node = encode_dir_node(&entries);
1386 self.dir_nodes.push(dir_node);
1387 self.inodes.push(PendingInode {
1388 number: inode_number,
1389 mode: 0o040_755,
1390 mtime_ns,
1391 content: PendingContent::Directory(entries),
1392 });
1393 Ok(inode_number)
1394 } else if file_type.is_file() {
1395 self.file_count += 1;
1396 let inode_number = self.alloc_inode();
1397 let file_len = meta.len();
1398
1399 if file_len <= u64::try_from(self.inline_threshold).unwrap_or(u64::MAX) {
1400 let data = std::fs::read(path)?;
1401 self.inodes.push(PendingInode {
1402 number: inode_number,
1403 mode: 0o100_644,
1404 mtime_ns,
1405 content: PendingContent::Inline(data),
1406 });
1407 } else {
1408 let pf = PendingFile {
1410 inode_number,
1411 path: path.to_path_buf(),
1412 mtime_ns,
1413 file_len,
1414 };
1415 if let Some(sink) = &self.pending_sink {
1416 sink.send(pf).map_err(|_| {
1422 WriteError::Io(std::io::Error::other("walk: compress pipeline shut down"))
1423 })?;
1424 } else {
1425 self.pending_files.push(pf);
1426 }
1427 }
1428 Ok(inode_number)
1429 } else {
1430 Err(WriteError::Io(std::io::Error::new(
1431 std::io::ErrorKind::Unsupported,
1432 format!("unsupported file type: {}", path.display()),
1433 )))
1434 }
1435 }
1436
1437 fn train_and_apply_dictionary(&mut self, dictionaries: &crate::config::DictionaryConfig) {
1452 let cleanup = |ctx: &mut Self| {
1453 for d in &mut ctx.drops {
1454 d.plaintext = None;
1455 }
1456 ctx.dict_samples_by_class.clear();
1457 };
1458
1459 if !dictionaries.enabled {
1460 cleanup(self);
1461 return;
1462 }
1463
1464 let target = usize::try_from(dictionaries.max_dict_size).unwrap_or(65_536);
1465 let min_class = usize::try_from(dictionaries.min_class_size).unwrap_or(0);
1466
1467 let text_classes = [
1471 crate::classifier::Class::Text,
1472 crate::classifier::Class::Code,
1473 crate::classifier::Class::Sparse,
1474 ];
1475 let binary_classes = [crate::classifier::Class::Binary];
1476
1477 let text_samples: Vec<&[u8]> = text_classes
1479 .iter()
1480 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1481 .map(Vec::as_slice)
1482 .collect();
1483 let trainer = crate::dictionary::TrainerKind::from_config_str(&dictionaries.trainer);
1484 if text_samples.len() >= min_class {
1485 if let Some(dict) =
1486 crate::dictionary::train_zstd_with_trainer(0, &text_samples, target, trainer)
1487 {
1488 self.trained_dicts_by_class
1489 .insert(crate::classifier::Class::Text, dict);
1490 }
1491 }
1492 let binary_samples: Vec<&[u8]> = binary_classes
1493 .iter()
1494 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1495 .map(Vec::as_slice)
1496 .collect();
1497 if binary_samples.len() >= min_class {
1498 if let Some(dict) =
1499 crate::dictionary::train_zstd_with_trainer(1, &binary_samples, target, trainer)
1500 {
1501 self.trained_dicts_by_class
1502 .insert(crate::classifier::Class::Binary, dict);
1503 }
1504 }
1505
1506 for d in self.drops.iter_mut() {
1509 if d.codec != limnifs_core::codec::CODEC_ZSTD {
1510 continue;
1511 }
1512 let Some(plaintext) = d.plaintext.clone() else {
1513 continue;
1514 };
1515 let class = self.classifier.classify(&plaintext);
1516 let dict_class = if text_classes.contains(&class) {
1517 crate::classifier::Class::Text
1518 } else if binary_classes.contains(&class) {
1519 crate::classifier::Class::Binary
1520 } else {
1521 continue;
1522 };
1523 let Some(dict) = self.trained_dicts_by_class.get(&dict_class) else {
1524 continue;
1525 };
1526 let Ok(dict_compressed) = dict.compress(&plaintext) else {
1527 continue;
1528 };
1529 if dict_compressed.len() < d.compressed.len() {
1530 d.compressed = dict_compressed.into();
1531 d.dict_id = dict.id;
1532 }
1533 }
1534
1535 cleanup(self);
1536 }
1537
1538 fn trace_phase(label: &str, start: std::time::Instant) {
1540 if std::env::var_os("LIMNIFS_TRACE_ASSEMBLE").is_some() {
1541 eprintln!("[assemble] {label}: {:?}", start.elapsed());
1542 }
1543 }
1544
1545 fn assemble(mut self) -> WriteArtifact {
1546 let t_assemble = std::time::Instant::now();
1547 let inode_count = self.inodes.len();
1548 let dir_count = self.dir_count;
1549 let drop_count = self.drops.len();
1550
1551 let t = std::time::Instant::now();
1557 let slabs = pack_slabs(&self.drops);
1558 Self::trace_phase("pack_slabs", t);
1559
1560 let t = std::time::Instant::now();
1564 self.build_shared_inline_table();
1565 Self::trace_phase("shared_inline_table", t);
1566
1567 let mut metadata_blob = Vec::new();
1568 metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
1569 for inode in &self.inodes {
1570 self.encode_inode(&mut metadata_blob, inode);
1571 }
1572 metadata_blob
1573 .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
1574 for node in &self.dir_nodes {
1575 metadata_blob.extend_from_slice(&node.bytes);
1576 }
1577 if !self.shared_inline_table.is_empty() {
1580 metadata_blob.extend_from_slice(
1581 &u32::try_from(self.shared_inline_table.len())
1582 .unwrap()
1583 .to_le_bytes(),
1584 );
1585 for entry in &self.shared_inline_table {
1586 let len = u32::try_from(entry.len()).expect("shared entry fits u32");
1587 metadata_blob.extend_from_slice(&len.to_le_bytes());
1588 metadata_blob.extend_from_slice(entry);
1589 }
1590 }
1591
1592 Self::trace_phase("metadata_encode", t);
1593 let uncompressed_len =
1600 u32::try_from(metadata_blob.len()).expect("metadata blob length fits u32");
1601 let t = std::time::Instant::now();
1602 let metadata_hash = hash_section(&metadata_blob);
1603 let metadata_codec = self.metadata_codec;
1604 let metadata_quality = if metadata_blob.len() > METADATA_LARGE_BLOB_THRESHOLD {
1605 METADATA_LARGE_BLOB_QUALITY
1606 } else {
1607 METADATA_SMALL_BLOB_QUALITY
1608 };
1609 let compressed_blob = if metadata_codec == limnifs_core::codec::CODEC_BROTLI {
1610 limnifs_core::codec::compress_brotli_with_quality(&metadata_blob, metadata_quality)
1611 .unwrap_or_else(|_| metadata_blob.clone())
1612 } else {
1613 limnifs_core::codec::compress(metadata_codec, &metadata_blob)
1614 .unwrap_or_else(|_| metadata_blob.clone())
1615 };
1616 Self::trace_phase("metadata_compress", t);
1617 let (on_wire_codec, on_wire_blob) = if compressed_blob.len() < metadata_blob.len() {
1618 (metadata_codec, compressed_blob)
1619 } else {
1620 (limnifs_core::codec::CODEC_STORE, metadata_blob.clone())
1621 };
1622
1623 let (metadata_sidecar, inline_data, metadata_locator_count) =
1627 if on_wire_blob.len() > METADATA_EXTERNALIZE_THRESHOLD {
1628 let locator = "file:metadata.bin".to_owned();
1629 let sidecar = MetadataSidecar {
1630 bytes: on_wire_blob.clone(),
1631 locator,
1632 };
1633 (Some(sidecar), None, 1u32)
1634 } else {
1635 (None, Some(on_wire_blob.clone()), 0u32)
1636 };
1637
1638 let mut manifest = Vec::new();
1639
1640 let header_start = manifest.len();
1641 manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
1642 let header_end = manifest.len();
1643
1644 let flags_start = manifest.len();
1645 manifest.push(FEATURE_FLAGS_SECTION_VERSION);
1646 manifest.extend_from_slice(&0u32.to_le_bytes());
1647 let flags_end = manifest.len();
1648
1649 let meta_ref_start = manifest.len();
1652 manifest.push(METADATA_REFERENCE_SECTION_VERSION_2);
1653 manifest.extend_from_slice(&metadata_hash);
1654 manifest.extend_from_slice(&uncompressed_len.to_le_bytes());
1655 manifest.push(on_wire_codec);
1656 manifest.extend_from_slice(&metadata_locator_count.to_le_bytes());
1657 if let Some(sidecar) = &metadata_sidecar {
1658 let loc_bytes = sidecar.locator.as_bytes();
1659 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1660 manifest.extend_from_slice(&loc_len.to_le_bytes());
1661 manifest.extend_from_slice(loc_bytes);
1662 }
1663 match &inline_data {
1664 Some(blob) => {
1665 let inline_len = u32::try_from(blob.len()).expect("metadata fits u32");
1666 manifest.extend_from_slice(&inline_len.to_le_bytes());
1667 manifest.extend_from_slice(blob);
1668 }
1669 None => {
1670 manifest.extend_from_slice(&0u32.to_le_bytes());
1671 }
1672 }
1673 let meta_ref_end = manifest.len();
1674
1675 let slab_index_start = manifest.len();
1676 manifest.push(SLAB_INDEX_SECTION_VERSION);
1677 manifest.extend_from_slice(&u32::try_from(slabs.len()).unwrap().to_le_bytes());
1678 for slab in &slabs {
1679 manifest.extend_from_slice(&slab.id.to_bytes());
1680 manifest.extend_from_slice(&1u32.to_le_bytes());
1681 let loc_bytes = slab.locator.as_bytes();
1682 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1683 manifest.extend_from_slice(&loc_len.to_le_bytes());
1684 manifest.extend_from_slice(loc_bytes);
1685 }
1686 let slab_index_end = manifest.len();
1687
1688 let history_start = manifest.len();
1689 manifest.push(HISTORY_SECTION_VERSION);
1690 manifest.extend_from_slice(&1u32.to_le_bytes());
1691 manifest.push(0x01);
1692 manifest.extend_from_slice(&0u64.to_le_bytes());
1693 manifest.extend_from_slice(&0u32.to_le_bytes());
1694 manifest.extend_from_slice(&0u32.to_le_bytes());
1695 let history_end = manifest.len();
1696
1697 let profile_desc_start = manifest.len();
1702 if let Some(ref name) = self.profile_name {
1703 let desc = limnifs_core::profile_descriptor::ProfileDescriptor {
1704 version: limnifs_core::profile_descriptor::PROFILE_DESCRIPTOR_SECTION_VERSION,
1705 profile_name: Some(name.clone()),
1706 blake3_hashing: true,
1707 cross_file_dedup: true,
1708 content_classification: !self.categorizers_disabled,
1709 integrity_verify: true,
1710 read_write: self.rw_mode,
1711 auto_turnover: self.auto_turnover,
1712 };
1713 limnifs_core::profile_descriptor::encode_profile_descriptor(&desc, &mut manifest);
1714 }
1715 let profile_desc_end = manifest.len();
1716
1717 if !self.trained_dicts_by_class.is_empty() {
1723 let dicts: Vec<_> = self
1724 .trained_dicts_by_class
1725 .values()
1726 .map(|d| limnifs_core::dictionary_section::Dictionary {
1727 codec_id: d.codec,
1728 class_id: d.id,
1729 data: d.content.clone(),
1730 })
1731 .collect();
1732 let section = limnifs_core::dictionary_section::DictionarySection {
1733 version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
1734 dicts,
1735 };
1736 limnifs_core::dictionary_section::encode_dictionary_section(§ion, &mut manifest);
1737 }
1738
1739 let dictionary_end = manifest.len();
1740
1741 let delta_linkage_hash = if let Some(base_root) = self.base_root {
1747 let delta_start = manifest.len();
1748 manifest.push(limnifs_core::delta_linkage::DELTA_LINKAGE_SECTION_VERSION);
1754 manifest.extend_from_slice(&base_root);
1755 manifest.extend_from_slice(&0u32.to_le_bytes());
1756 hash_section(&manifest[delta_start..])
1757 } else {
1758 hash_empty_section()
1759 };
1760 let _ = dictionary_end;
1761
1762 let hashes = SectionHashes {
1763 metadata: metadata_hash,
1764 format_header: hash_section(&manifest[header_start..header_end]),
1765 feature_flags: hash_section(&manifest[flags_start..flags_end]),
1766 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
1767 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
1768 crypto_params: hash_empty_section(),
1769 ec_params: hash_empty_section(),
1770 dms_policy: hash_empty_section(),
1771 delta_linkage: delta_linkage_hash,
1772 history: hash_section(&manifest[history_start..history_end]),
1773 };
1785 let merkle_root = compute_merkle_root(&hashes);
1786
1787 WriteArtifact {
1788 bytes: manifest,
1789 merkle_root,
1790 slabs,
1791 metadata_sidecar,
1792 inode_count,
1793 file_count: self.file_count,
1794 dir_count,
1795 drop_count,
1796 root_inode_number: self.root_inode_number,
1797 }
1798 }
1799
1800 fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
1801 out.extend_from_slice(&inode.number.to_le_bytes());
1802 out.extend_from_slice(&inode.mode.to_le_bytes());
1803 out.extend_from_slice(&0u32.to_le_bytes());
1804 out.extend_from_slice(&0u32.to_le_bytes());
1805 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1806 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1807 out.extend_from_slice(&1u32.to_le_bytes());
1808 match &inode.content {
1809 PendingContent::Inline(data) => {
1810 let h = hash_section(data);
1811 if let Some(&idx) = self.shared_inline_map.get(&h) {
1812 out.push(INODE_FLAG_INLINE_DATA | INODE_FLAG_SHARED_INLINE);
1814 out.extend_from_slice(&(idx as u32).to_le_bytes());
1815 } else {
1816 out.push(INODE_FLAG_INLINE_DATA);
1817 let len = u32::try_from(data.len()).expect("data fits u32");
1818 out.extend_from_slice(&len.to_le_bytes());
1819 out.extend_from_slice(data);
1820 }
1821 }
1822 PendingContent::DropBacked { file_len, slices } => {
1823 out.push(0x00);
1824 let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
1825 out.extend_from_slice(&slice_count.to_le_bytes());
1826 for slice in slices {
1827 out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
1828 out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
1829 out.extend_from_slice(&slice.drop_id);
1830 out.extend_from_slice(&0u32.to_le_bytes());
1832 let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
1836 .expect("slice range fits u32");
1837 out.extend_from_slice(&drop_byte_len.to_le_bytes());
1838 }
1839 let _ = file_len;
1840 }
1841 PendingContent::Directory(entries) => {
1842 out.push(0x00);
1843 let node = self
1844 .dir_nodes
1845 .iter()
1846 .find(|n| n.entries == *entries)
1847 .expect("directory node must exist");
1848 out.extend_from_slice(&node.hash);
1849 }
1850 }
1851 }
1852}
1853
1854fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
1855 let mut bytes = Vec::new();
1856 bytes.push(1u8);
1857 let count = u32::try_from(entries.len()).expect("entry count fits u32");
1858 bytes.extend_from_slice(&count.to_le_bytes());
1859 for (name, inode_number, entry_type) in entries {
1860 let name_bytes = name.as_bytes();
1861 let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
1862 bytes.extend_from_slice(&name_len.to_le_bytes());
1863 bytes.extend_from_slice(name_bytes);
1864 bytes.extend_from_slice(&inode_number.to_le_bytes());
1865 bytes.push(*entry_type);
1866 }
1867 let hash = hash_section(&bytes);
1868 DirNode {
1869 entries: entries.to_vec(),
1870 bytes,
1871 hash,
1872 }
1873}
1874
1875fn pack_slabs(drops: &[PendingDrop]) -> Vec<SlabArtifact> {
1884 let local_drops: Vec<&PendingDrop> = drops
1889 .iter()
1890 .filter(|d| d.codec != limnifs_core::codec::CODEC_REFERENCED)
1891 .collect();
1892 if local_drops.is_empty() {
1893 return Vec::new();
1894 }
1895
1896 let max_content = MAX_SLAB_TOTAL_BYTES.saturating_sub(SLAB_HEADER_LEN);
1897
1898 let mut slab_groups: Vec<Vec<&PendingDrop>> = Vec::new();
1903 let mut current: Vec<&PendingDrop> = Vec::new();
1904 let mut current_size: usize = 0;
1905
1906 for drop in &local_drops {
1907 let footprint = drop.slab_footprint();
1908 if !current.is_empty() && current_size + footprint > max_content {
1909 slab_groups.push(std::mem::take(&mut current));
1910 current_size = 0;
1911 }
1912 current.push(*drop);
1913 current_size += footprint;
1914 }
1915 if !current.is_empty() {
1916 slab_groups.push(current);
1917 }
1918
1919 use rayon::prelude::*;
1925 slab_groups
1926 .par_iter()
1927 .enumerate()
1928 .map(|(ordinal, group)| {
1929 let ordinal_u64 = u64::try_from(ordinal).expect("slab count fits u64");
1930 encode_slab(ordinal_u64, group)
1931 })
1932 .collect()
1933}
1934
1935fn encode_slab(ordinal: u64, drops: &[&PendingDrop]) -> SlabArtifact {
1939 const DROP_RECORD_LEN: usize = 49;
1945 let mut drop_records = Vec::with_capacity(drops.len() * DROP_RECORD_LEN);
1946 let mut solid_window = Vec::new();
1947 let mut drop_ids = Vec::with_capacity(drops.len());
1948 let mut offset_in_window: u32 = 0;
1949
1950 for drop in drops {
1951 let plaintext_len = drop.plaintext_len_value();
1952 let window_len = drop.len_in_window();
1953 drop_records.extend_from_slice(&drop.id);
1954 drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
1955 drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
1957 drop_records.push(0x00); drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
1959 drop_records.extend_from_slice(&window_len.to_le_bytes());
1960 drop_records.push(drop.dict_id); solid_window.extend_from_slice(&drop.compressed);
1962 drop_ids.push(drop.id);
1963 offset_in_window = offset_in_window
1964 .checked_add(window_len)
1965 .expect("slab window size fits u32");
1966 }
1967
1968 let slab_content = [&drop_records[..], &solid_window[..]].concat();
1969 let slab_hash = hash_section(&slab_content);
1970 let slab_id = SlabId::new(ordinal, slab_hash);
1971
1972 let total_length = SLAB_HEADER_LEN + slab_content.len();
1973 let mut slab_bytes = Vec::with_capacity(total_length);
1974 slab_bytes.extend_from_slice(b"LIM1");
1975 slab_bytes.extend_from_slice(&1u16.to_le_bytes());
1976 slab_bytes.extend_from_slice(&slab_id.to_bytes());
1977 slab_bytes.extend_from_slice(
1978 &u64::try_from(total_length)
1979 .unwrap_or(u64::MAX)
1980 .to_le_bytes(),
1981 );
1982 slab_bytes.push(0x00);
1983 slab_bytes.push(0x00);
1984 slab_bytes.extend_from_slice(&slab_content);
1985
1986 let locator = format!("file:slab-{ordinal}.bin");
1987
1988 SlabArtifact {
1989 id: slab_id,
1990 bytes: slab_bytes,
1991 locator,
1992 drop_ids,
1993 }
1994}
1995
1996#[cfg(test)]
1997fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
1998 let mut state = seed;
1999 let mut out = Vec::with_capacity(count);
2000 for _ in 0..count {
2001 state = state
2002 .wrapping_mul(6_364_136_223_846_793_005)
2003 .wrapping_add(1_442_695_040_888_963_407);
2004 out.push(u8::try_from(state >> 56).expect("fits u8"));
2005 }
2006 out
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011 use super::*;
2012 use limnifs_core::ManifestCursor;
2013
2014 #[test]
2015 fn write_stream_packs_single_named_stream() {
2016 let temp = std::env::temp_dir().join(format!(
2020 "limnifs-write-stream-test-{}-{}",
2021 std::process::id(),
2022 std::time::SystemTime::now()
2023 .duration_since(std::time::UNIX_EPOCH)
2024 .unwrap()
2025 .as_nanos()
2026 ));
2027 std::fs::create_dir_all(&temp).expect("create temp dir");
2028
2029 let content = b"stream test content line\n".repeat(10_000); let cursor = std::io::Cursor::new(content.clone());
2031 let config = WriteConfig::default_v0_1();
2032 let artifact = write_stream("streamed.txt", cursor, &config).expect("write_stream");
2033
2034 assert!(!artifact.bytes.is_empty(), "manifest bytes non-empty");
2035 assert!(!artifact.slabs.is_empty(), "at least one slab produced");
2036 let total_drop_bytes: usize = artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2041 assert!(total_drop_bytes > 0, "drops non-empty");
2042
2043 let _ = std::fs::remove_dir_all(&temp);
2044 }
2045
2046 #[test]
2047 fn write_layer_references_base_drops() {
2048 let temp = std::env::temp_dir().join(format!(
2054 "limnifs-write-layer-test-{}-{}",
2055 std::process::id(),
2056 std::time::SystemTime::now()
2057 .duration_since(std::time::UNIX_EPOCH)
2058 .unwrap()
2059 .as_nanos()
2060 ));
2061 std::fs::create_dir_all(&temp).expect("create temp dir");
2062
2063 let base_dir = temp.join("base");
2065 std::fs::create_dir_all(&base_dir).expect("base dir");
2066 let text = b"layer test content line\n".repeat(50_000); std::fs::write(base_dir.join("shared.txt"), &text).expect("write shared");
2068 std::fs::write(base_dir.join("base-only.txt"), b"only in base").expect("write base-only");
2069
2070 let config = WriteConfig::default_v0_1();
2071 let base_artifact = write_directory_with_config(&base_dir, &config).expect("base");
2072
2073 let base_manifest = temp.join("base.lim");
2074 std::fs::write(&base_manifest, &base_artifact.bytes).expect("write base manifest");
2075 for slab in &base_artifact.slabs {
2076 let slab_name = slab.locator.strip_prefix("file:").unwrap_or(&slab.locator);
2077 std::fs::write(temp.join(slab_name), &slab.bytes).expect("write base slab");
2078 }
2079
2080 let layer_dir = temp.join("layer");
2082 std::fs::create_dir_all(&layer_dir).expect("layer dir");
2083 std::fs::write(layer_dir.join("shared.txt"), &text).expect("write shared in layer");
2084 std::fs::write(layer_dir.join("new.txt"), b"fresh content in layer").expect("write new");
2085
2086 let layer_artifact = write_layer(&base_manifest, &layer_dir, &config).expect("layer");
2087
2088 let layer_slab_bytes: usize = layer_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2091 let base_slab_bytes: usize = base_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2092 assert!(
2093 layer_slab_bytes < base_slab_bytes / 4,
2094 "layer slabs ({}) should be much smaller than base ({}) — layering failed",
2095 layer_slab_bytes,
2096 base_slab_bytes
2097 );
2098
2099 let base_root = base_artifact.merkle_root.as_bytes();
2102 assert!(
2103 layer_artifact
2104 .bytes
2105 .windows(32)
2106 .any(|w| w == base_root.as_slice()),
2107 "layer manifest must contain base's ManifestRoot bytes"
2108 );
2109
2110 let _ = std::fs::remove_dir_all(&temp);
2111 }
2112
2113 #[test]
2114 fn tournament_short_circuits_on_highly_compressible_chunk() {
2115 let chunk = b"hello world ".repeat(500);
2118 let tunables = limnifs_core::codec::CodecTunables::default();
2119 let tournament = TournamentSpec {
2120 codec_ids: vec![
2121 limnifs_core::codec::CODEC_LZ4,
2122 limnifs_core::codec::CODEC_BROTLI,
2123 ],
2124 min_size: 16,
2125 skip_for_binary: false,
2126 short_circuit_permille: 250,
2127 };
2128 let (codec_id, compressed) = compress_chunk_with_tournament(
2129 &chunk,
2130 classifier::Class::Text,
2131 limnifs_core::codec::CODEC_BROTLI,
2132 limnifs_core::codec::CODEC_LZ4,
2133 &tunables,
2134 &tournament,
2135 );
2136 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2137 assert!(compressed.len() < chunk.len());
2138 }
2139
2140 #[test]
2141 fn tournament_runs_all_codecs_when_short_circuit_disabled() {
2142 let chunk = b"hello world ".repeat(500);
2148 let tunables = limnifs_core::codec::CodecTunables::default();
2149 let tournament = TournamentSpec {
2150 codec_ids: vec![
2151 limnifs_core::codec::CODEC_LZ4,
2152 limnifs_core::codec::CODEC_BROTLI,
2153 limnifs_core::codec::CODEC_ZSTD,
2154 ],
2155 min_size: 16,
2156 skip_for_binary: false,
2157 short_circuit_permille: 0,
2158 };
2159 let (codec_id, compressed) = compress_chunk_with_tournament(
2160 &chunk,
2161 classifier::Class::Text,
2162 limnifs_core::codec::CODEC_BROTLI,
2163 limnifs_core::codec::CODEC_LZ4,
2164 &tunables,
2165 &tournament,
2166 );
2167 assert!(
2171 codec_id == limnifs_core::codec::CODEC_ZSTD
2172 || codec_id == limnifs_core::codec::CODEC_BROTLI,
2173 "expected ZSTD or Brotli to win, got codec {codec_id}"
2174 );
2175 assert!(compressed.len() < chunk.len());
2176 }
2177
2178 #[test]
2179 fn tournament_skips_for_binary_when_configured() {
2180 let chunk = vec![0u8; 4096];
2181 let tunables = limnifs_core::codec::CodecTunables::default();
2182 let tournament = TournamentSpec {
2183 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2184 min_size: 16,
2185 skip_for_binary: true,
2186 short_circuit_permille: 250,
2187 };
2188 let (codec_id, _compressed) = compress_chunk_with_tournament(
2189 &chunk,
2190 classifier::Class::Binary,
2191 limnifs_core::codec::CODEC_BROTLI,
2192 limnifs_core::codec::CODEC_LZ4,
2193 &tunables,
2194 &tournament,
2195 );
2196 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2198 }
2199
2200 #[test]
2201 fn tournament_small_chunk_uses_preferred_codec() {
2202 let chunk = b"tiny";
2203 let tunables = limnifs_core::codec::CodecTunables::default();
2204 let tournament = TournamentSpec {
2205 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2206 min_size: 1024,
2207 skip_for_binary: false,
2208 short_circuit_permille: 0,
2209 };
2210 let (codec_id, _compressed) = compress_chunk_with_tournament(
2211 chunk,
2212 classifier::Class::Text,
2213 limnifs_core::codec::CODEC_BROTLI,
2214 limnifs_core::codec::CODEC_LZ4,
2215 &tunables,
2216 &tournament,
2217 );
2218 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2220 }
2221
2222 #[test]
2223 fn tournament_falls_back_to_store_when_no_codec_compresses() {
2224 let chunk = pseudo_random_bytes(42, 4096);
2228 let tunables = limnifs_core::codec::CodecTunables::default();
2229 let tournament = TournamentSpec {
2230 codec_ids: vec![limnifs_core::codec::CODEC_LZ4],
2231 min_size: 16,
2232 skip_for_binary: false,
2233 short_circuit_permille: 0,
2234 };
2235 let (codec_id, compressed) = compress_chunk_with_tournament(
2236 &chunk,
2237 classifier::Class::Binary,
2238 limnifs_core::codec::CODEC_BROTLI,
2239 limnifs_core::codec::CODEC_LZ4,
2240 &tunables,
2241 &tournament,
2242 );
2243 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2244 assert_eq!(compressed.len(), chunk.len());
2245 }
2246
2247 #[test]
2248 fn dictionaries_enabled_emits_dictionary_section_when_enough_samples() {
2249 let temp = std::env::temp_dir().join(format!(
2254 "limnifs-write-test-{}-dict-{}",
2255 std::process::id(),
2256 std::time::SystemTime::now()
2257 .duration_since(std::time::UNIX_EPOCH)
2258 .map(|d| d.as_nanos() as u64)
2259 .unwrap_or(0),
2260 ));
2261 let _ = std::fs::remove_dir_all(&temp);
2262 std::fs::create_dir_all(&temp).expect("mkdir");
2263
2264 for i in 0..200 {
2267 let content = format!(
2269 "function test_case_{i}() {{ return constant + {i}; }}\n\
2270 // shared comment line {i}\n\
2271 struct Foo {{ x: i32 }} // type {i}\n"
2272 )
2273 .repeat(5);
2274 let path = temp.join(format!("file_{i:04}.txt"));
2275 std::fs::write(&path, content.as_bytes()).expect("write");
2276 }
2277
2278 let mut config = crate::profile::balanced();
2279 config.defaults.text_codec = "zstd".into();
2281 config.defaults.metadata_codec = "zstd".into();
2285 config.dictionaries.enabled = true;
2286 config.dictionaries.min_class_size = 50;
2287 config.dictionaries.max_dict_size = 8192;
2288
2289 let artifact = write_directory_with_config(&temp, &config).expect("write");
2290 std::fs::remove_dir_all(&temp).ok();
2291
2292 let mut cursor = ManifestCursor::new(&artifact.bytes);
2297 let _ = limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2298 let _ = limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2299 let _ = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta_ref");
2300 let _ = limnifs_core::parse_slab_index(&mut cursor).expect("slab_index");
2301 let _ = limnifs_core::parse_history(&mut cursor).expect("history");
2302 let _remaining = cursor.remaining_len();
2305 }
2306
2307 #[test]
2308 fn write_empty_directory() {
2309 let temp =
2310 std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
2311 std::fs::create_dir_all(&temp).expect("create temp dir");
2312 let artifact = write_directory(&temp).expect("write succeeds");
2313 std::fs::remove_dir_all(&temp).ok();
2314 assert!(artifact.inode_count >= 1);
2315 assert_eq!(artifact.file_count, 0);
2316 assert_eq!(artifact.dir_count, 1);
2317 assert!(artifact.slabs.is_empty());
2318 }
2319
2320 #[test]
2321 fn write_small_file_inline() {
2322 let temp =
2323 std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
2324 std::fs::create_dir_all(&temp).expect("create temp dir");
2325 std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
2326 let artifact = write_directory(&temp).expect("write succeeds");
2327 std::fs::remove_dir_all(&temp).ok();
2328 assert_eq!(artifact.file_count, 1);
2329 assert!(artifact.slabs.is_empty());
2330 assert_eq!(artifact.drop_count, 0);
2331 }
2332
2333 #[test]
2334 fn write_large_file_uses_slab() {
2335 let temp =
2336 std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
2337 std::fs::create_dir_all(&temp).expect("create temp dir");
2338 let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
2339 std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
2340 let artifact = write_directory(&temp).expect("write succeeds");
2341 std::fs::remove_dir_all(&temp).ok();
2342 assert_eq!(artifact.drop_count, 1);
2343 assert_eq!(artifact.slabs.len(), 1);
2344 }
2345
2346 #[test]
2347 fn write_mixed_inline_and_large() {
2348 let temp =
2349 std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
2350 std::fs::create_dir_all(&temp).expect("create temp dir");
2351 std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
2352 std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
2353 .expect("write large");
2354 let artifact = write_directory(&temp).expect("write succeeds");
2355 std::fs::remove_dir_all(&temp).ok();
2356 assert_eq!(artifact.file_count, 2);
2357 assert_eq!(artifact.drop_count, 1);
2358 assert_eq!(artifact.slabs.len(), 1);
2359 }
2360
2361 #[test]
2362 fn deduplicates_identical_large_files() {
2363 let temp =
2364 std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
2365 std::fs::create_dir_all(&temp).expect("create temp dir");
2366 let data = vec![0x77u8; INLINE_THRESHOLD + 10];
2367 std::fs::write(temp.join("a.bin"), &data).expect("write a");
2368 std::fs::write(temp.join("b.bin"), &data).expect("write b");
2369 let artifact = write_directory(&temp).expect("write succeeds");
2370 std::fs::remove_dir_all(&temp).ok();
2371 assert_eq!(artifact.drop_count, 1);
2372 }
2373
2374 #[test]
2375 fn write_and_verify_roundtrip() {
2376 let temp = std::env::temp_dir().join(format!(
2377 "limnifs-write-test-{}-roundtrip",
2378 std::process::id()
2379 ));
2380 std::fs::create_dir_all(&temp).expect("create temp dir");
2381 std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
2382 std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
2383 std::fs::create_dir_all(temp.join("sub")).expect("create sub");
2384 std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
2385 let artifact = write_directory(&temp).expect("write succeeds");
2386 std::fs::remove_dir_all(&temp).ok();
2387 assert_eq!(artifact.file_count, 3);
2388 assert_eq!(artifact.dir_count, 2);
2389
2390 let mut cursor = ManifestCursor::new(&artifact.bytes);
2391 limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2392 limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2393 let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
2394 assert!(meta_ref.is_inlined());
2395 let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
2396 assert_eq!(slab_index.len(), 0);
2397 limnifs_core::parse_history(&mut cursor).expect("history");
2398 }
2399
2400 #[test]
2401 fn write_deterministic() {
2402 let temp =
2403 std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
2404 std::fs::create_dir_all(&temp).expect("create temp dir");
2405 std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
2406
2407 let a1 = write_directory(&temp).expect("first write");
2408 let a2 = write_directory(&temp).expect("second write");
2409 std::fs::remove_dir_all(&temp).ok();
2410
2411 assert_eq!(a1.bytes, a2.bytes);
2412 assert_eq!(a1.merkle_root, a2.merkle_root);
2413 }
2414
2415 #[test]
2416 fn slab_parses_correctly() {
2417 let temp =
2418 std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
2419 std::fs::create_dir_all(&temp).expect("create temp dir");
2420 std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
2421 .expect("write big");
2422 let artifact = write_directory(&temp).expect("write succeeds");
2423 std::fs::remove_dir_all(&temp).ok();
2424
2425 let slab_bytes = &artifact.slabs[0].bytes;
2426 let mut cursor = ManifestCursor::new(slab_bytes);
2427 let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
2428 assert_eq!(slab_header.format_version, 1);
2429 assert!(!slab_header.is_sealed());
2430 assert!(!slab_header.has_erasure_coding());
2431
2432 let drop_record =
2433 limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
2434 assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
2435 }
2436
2437 #[test]
2438 fn fastcdc_produces_multiple_chunks_for_large_files() {
2439 let temp = std::env::temp_dir().join(format!(
2442 "limnifs-write-test-{}-cdc-multi",
2443 std::process::id()
2444 ));
2445 std::fs::create_dir_all(&temp).expect("create temp dir");
2446 let data = pseudo_random_bytes(42, 1024 * 1024);
2447 std::fs::write(temp.join("big.bin"), &data).expect("write big");
2448 let artifact = write_directory(&temp).expect("write succeeds");
2449 std::fs::remove_dir_all(&temp).ok();
2450 assert!(
2451 artifact.drop_count > 1,
2452 "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
2453 artifact.drop_count
2454 );
2455 }
2456
2457 #[test]
2458 fn fastcdc_deduplicates_shared_substrings() {
2459 let temp = std::env::temp_dir().join(format!(
2463 "limnifs-write-test-{}-cdc-dedup",
2464 std::process::id()
2465 ));
2466 std::fs::create_dir_all(&temp).expect("create temp dir");
2467 let shared = pseudo_random_bytes(7, 512 * 1024);
2468 let mut a = Vec::with_capacity(shared.len() + 1024);
2469 a.extend_from_slice(&pseudo_random_bytes(1, 1024));
2470 a.extend_from_slice(&shared);
2471 let mut b = Vec::with_capacity(shared.len() + 2048);
2472 b.extend_from_slice(&pseudo_random_bytes(2, 2048));
2473 b.extend_from_slice(&shared);
2474 std::fs::write(temp.join("a.bin"), &a).expect("write a");
2475 std::fs::write(temp.join("b.bin"), &b).expect("write b");
2476
2477 let temp_a = std::env::temp_dir().join(format!(
2479 "limnifs-write-test-{}-cdc-dedup-a",
2480 std::process::id()
2481 ));
2482 std::fs::create_dir_all(&temp_a).expect("create temp_a");
2483 std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
2484 let artifact_a = write_directory(&temp_a).expect("a writes");
2485 std::fs::remove_dir_all(&temp_a).ok();
2486
2487 let temp_b = std::env::temp_dir().join(format!(
2488 "limnifs-write-test-{}-cdc-dedup-b",
2489 std::process::id()
2490 ));
2491 std::fs::create_dir_all(&temp_b).expect("create temp_b");
2492 std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
2493 let artifact_b = write_directory(&temp_b).expect("b writes");
2494 std::fs::remove_dir_all(&temp_b).ok();
2495
2496 let artifact_both = write_directory(&temp).expect("both write");
2497 std::fs::remove_dir_all(&temp).ok();
2498
2499 let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
2500 assert!(
2501 artifact_both.drop_count < sum_alone,
2502 "expected dedup win: both together = {} drops, sum alone = {} drops",
2503 artifact_both.drop_count,
2504 sum_alone
2505 );
2506 }
2507
2508 #[test]
2509 fn slab_splits_when_content_exceeds_ceiling() {
2510 let temp =
2516 std::env::temp_dir().join(format!("limnifs-write-test-{}-split", std::process::id()));
2517 std::fs::create_dir_all(&temp).expect("create temp dir");
2518 for i in 0..7u32 {
2519 let data = pseudo_random_bytes(u64::from(i), 10 * 1024 * 1024);
2521 std::fs::write(temp.join(format!("big-{i}.bin")), &data).expect("write big");
2522 }
2523 let artifact = write_directory(&temp).expect("write succeeds");
2524 std::fs::remove_dir_all(&temp).ok();
2525
2526 assert!(
2528 artifact.slabs.len() >= 2,
2529 "expected at least 2 slabs for 70 MiB of incompressible data, got {}",
2530 artifact.slabs.len()
2531 );
2532 for slab in &artifact.slabs {
2533 assert!(
2534 slab.bytes.len() <= MAX_SLAB_TOTAL_BYTES,
2535 "slab {} is {} bytes (> {} ceiling)",
2536 slab.id.ordinal,
2537 slab.bytes.len(),
2538 MAX_SLAB_TOTAL_BYTES,
2539 );
2540 }
2541 let total_drop_ids: usize = artifact.slabs.iter().map(|s| s.drop_ids.len()).sum();
2543 assert_eq!(
2544 total_drop_ids, artifact.drop_count,
2545 "drop_ids count across slabs must match WriteArtifact.drop_count",
2546 );
2547 }
2548}