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.base_drop_index = Some(base_drop_index);
382 ctx.base_root = Some(base_root);
383
384 let root_inode_number = ctx.walk(root)?;
386 ctx.root_inode_number = root_inode_number;
387 write_directory_body(&mut ctx, config)?;
388 Ok(ctx.assemble())
389}
390
391fn load_base_drop_index(
395 base_image: &Path,
396) -> Result<(std::collections::HashSet<[u8; 32]>, [u8; 32]), WriteError> {
397 let manifest_bytes = std::fs::read(base_image)?;
398 let mut cursor = ManifestCursor::new(&manifest_bytes);
399 let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
400 let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
402 let _ = limnifs_core::parse_metadata_reference(&mut cursor);
403 let slab_index = parse_slab_index(&mut cursor).map_err(io_core)?;
404 let store = SlabStore::load_mmap(base_image, &slab_index).map_err(io_core)?;
405 let drop_set: std::collections::HashSet<[u8; 32]> = store.drop_index_keys().copied().collect();
406 let root = *compute_merkle_root_from_sections(&manifest_bytes).as_bytes();
412 Ok((drop_set, root))
413}
414
415fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
421 use limnifs_core::SectionHashes;
422 let mut cursor = ManifestCursor::new(manifest);
423 let header_start = 0;
424 if parse_manifest_header(&mut cursor).is_err() {
425 return ManifestRoot::from_bytes([0u8; 32]);
427 }
428 let header_end = cursor.position();
429 let flags_start = header_end;
431 let flags_end = match limnifs_core::parse_feature_flags_section(&mut cursor) {
432 Ok(_) => cursor.position(),
433 Err(_) => flags_start,
434 };
435 let meta_ref_start = flags_end;
436 let metadata_reference = match limnifs_core::parse_metadata_reference(&mut cursor) {
437 Ok(m) => Some(m),
438 Err(_) => None,
439 };
440 let meta_ref_end = cursor.position();
441 let slab_index_start = meta_ref_end;
442 let _ = parse_slab_index(&mut cursor);
443 let slab_index_end = cursor.position();
444 let history_start = slab_index_end;
445 let _ = limnifs_core::parse_history(&mut cursor);
446 let history_end = cursor.position();
447
448 let hashes = SectionHashes {
449 metadata: metadata_reference
450 .map(|m| m.metadata_hash)
451 .unwrap_or_else(hash_empty_section),
452 format_header: hash_section(&manifest[header_start..header_end]),
453 feature_flags: hash_section(&manifest[flags_start..flags_end]),
454 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
455 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
456 crypto_params: hash_empty_section(),
457 ec_params: hash_empty_section(),
458 dms_policy: hash_empty_section(),
459 delta_linkage: hash_empty_section(),
460 history: hash_section(&manifest[history_start..history_end]),
461 };
462 compute_merkle_root(&hashes)
463}
464
465fn io_core(e: limnifs_core::CoreError) -> WriteError {
466 WriteError::Io(std::io::Error::other(format!("base image load: {e}")))
467}
468
469fn write_directory_body(ctx: &mut WriteContext, config: &WriteConfig) -> Result<(), WriteError> {
474 use rayon::prelude::*;
475
476 ctx.metadata_codec = config
477 .metadata_codec_id()
478 .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
479
480 let pending = std::mem::take(&mut ctx.pending_files);
481 if pending.is_empty() {
482 return Ok(());
483 }
484 let chunker = ctx.chunker.clone();
485 let classifier = ctx.classifier;
486 let text_codec = config.text_codec_id().unwrap_or(0x04);
487 let binary_codec = config.binary_codec_id().unwrap_or(0x01);
488 let tunables = config.to_core_tunables();
489 let use_categorizers = !config.categorizers.is_empty();
490 let skip_chunking = config.skip_chunking;
491 let registry = config
492 .codec_registry()
493 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
494 let tournament_codec_ids: Vec<u8> = config
495 .tournament
496 .codecs
497 .iter()
498 .filter_map(|n| registry.lookup_by_name(n))
499 .collect();
500 let tournament_spec = TournamentSpec {
501 codec_ids: tournament_codec_ids,
502 min_size: config.tournament.min_size_threshold as usize,
503 skip_for_binary: config.tournament.skip_for_binary,
504 short_circuit_permille: config.tournament.short_circuit_threshold,
505 };
506 let base_drop_index = ctx.base_drop_index.as_ref();
507 let results: Vec<ChunkedFileResult> = pending
508 .par_iter()
509 .map(|pf| {
510 process_file(
511 pf,
512 &chunker,
513 classifier,
514 text_codec,
515 binary_codec,
516 &tunables,
517 use_categorizers,
518 skip_chunking,
519 &tournament_spec,
520 base_drop_index,
521 )
522 })
523 .collect::<Result<Vec<_>, _>>()?;
524
525 for (pf, result) in pending.iter().zip(results) {
526 ctx.merge_chunked_file(pf, result);
527 }
528 ctx.train_and_apply_dictionary(&config.dictionaries);
529 Ok(())
530}
531
532pub fn write_directory_with_config(
534 root: &Path,
535 config: &WriteConfig,
536) -> Result<WriteArtifact, WriteError> {
537 let mut ctx = WriteContext::new();
538 ctx.categorizers_disabled = config.categorizers.is_empty();
539 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
540 ctx.auto_turnover = config.turnover_threshold > 0;
541 ctx.collect_dict_samples = config.dictionaries.enabled;
542
543 let root_inode_number = ctx.walk(root)?;
544 ctx.root_inode_number = root_inode_number;
545 write_directory_body(&mut ctx, config)?;
546 Ok(ctx.assemble())
547}
548
549pub(crate) type RawDrop = ([u8; 32], Vec<u8>, std::sync::Arc<[u8]>, u8);
556pub(crate) struct ChunkedFileResult {
558 drops: Vec<RawDrop>, slices: Vec<PendingSlice>,
560}
561
562struct TournamentSpec {
570 codec_ids: Vec<u8>,
574 min_size: usize,
578 skip_for_binary: bool,
582 short_circuit_permille: u32,
587}
588
589fn process_whole_file_drop(
602 pf: &PendingFile,
603 data: &[u8],
604 cat: file_categorizer::Categorization,
605 tunables: &limnifs_core::codec::CodecTunables,
606) -> Result<ChunkedFileResult, WriteError> {
607 let _ = pf;
608 let drop_id = hash_section(data);
609 let file_len = u64::try_from(data.len()).unwrap_or(u64::MAX);
610
611 let (mut best_codec, mut best_compressed): (u8, std::sync::Arc<[u8]>) =
616 match limnifs_core::codec::compress_with_tunables(
617 limnifs_core::codec::CODEC_BROTLI,
618 data,
619 tunables,
620 ) {
621 Ok(c) => (limnifs_core::codec::CODEC_BROTLI, c.into()),
622 Err(_) => match limnifs_core::codec::compress_with_tunables(
623 limnifs_core::codec::CODEC_ZSTD,
624 data,
625 tunables,
626 ) {
627 Ok(c) => (limnifs_core::codec::CODEC_ZSTD, c.into()),
628 Err(_) => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
629 },
630 };
631
632 let brotli_ratio = best_compressed.len() as f64 / data.len() as f64;
636 if brotli_ratio > 0.05 {
637 if let Ok(zstd_c) = limnifs_core::codec::compress_with_tunables(
638 limnifs_core::codec::CODEC_ZSTD,
639 data,
640 tunables,
641 ) {
642 if zstd_c.len() < best_compressed.len() {
643 best_codec = limnifs_core::codec::CODEC_ZSTD;
644 best_compressed = zstd_c.into();
645 }
646 }
647 }
648
649 let general_ratio = best_compressed.len() as f64 / data.len() as f64;
655 if general_ratio > 0.15 || cat.codec_id == limnifs_core::codec::CODEC_RICEPP {
656 let spec_result = if cat.codec_id == limnifs_core::codec::CODEC_FSST_BROTLI {
660 limnifs_core::codec::fsst_brotli::compress_with_baseline(data, Some(&best_compressed))
661 } else {
662 limnifs_core::codec::compress(cat.codec_id, data)
663 };
664 if let Ok(spec_c) = spec_result {
665 if spec_c.len() < best_compressed.len() {
666 best_codec = cat.codec_id;
667 best_compressed = spec_c.into();
668 }
669 }
670 }
671
672 Ok(ChunkedFileResult {
673 drops: vec![(drop_id, data.to_vec(), best_compressed, best_codec)],
674 slices: vec![PendingSlice {
675 drop_id,
676 file_byte_start: 0,
677 file_byte_end: file_len,
678 }],
679 })
680}
681
682fn compress_chunk_with_tournament(
704 chunk: &[u8],
705 class: classifier::Class,
706 text_codec: u8,
707 binary_codec: u8,
708 tunables: &limnifs_core::codec::CodecTunables,
709 tournament: &TournamentSpec,
710) -> (u8, std::sync::Arc<[u8]>) {
711 use classifier::Class;
712
713 let preferred = match class {
714 Class::Binary => binary_codec,
715 Class::Text | Class::Code | Class::Sparse => text_codec,
716 _ => limnifs_core::codec::CODEC_STORE,
717 };
718
719 if preferred == limnifs_core::codec::CODEC_STORE {
720 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
721 }
722 if class == Class::Binary && tournament.skip_for_binary {
723 return compress_chunk_one(chunk, preferred, tunables);
724 }
725 if chunk.len() < tournament.min_size {
726 return compress_chunk_one(chunk, preferred, tunables);
727 }
728
729 let mut best: Option<(u8, std::sync::Arc<[u8]>)> = None;
730 for &codec_id in &tournament.codec_ids {
731 if codec_id == limnifs_core::codec::CODEC_STORE {
732 continue;
733 }
734 let c = match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
735 Ok(c) => c,
736 Err(_) => continue,
737 };
738 if c.len() >= chunk.len() {
739 continue;
740 }
741 let ratio_permille = (c.len() as u64 * 1000 / chunk.len() as u64) as u32;
742 let is_best_so_far = best.as_ref().map_or(true, |(_, b)| c.len() < b.len());
743 if is_best_so_far {
744 best = Some((codec_id, c.into()));
745 }
746 if tournament.short_circuit_permille > 0
747 && ratio_permille <= tournament.short_circuit_permille
748 {
749 break;
750 }
751 }
752
753 best.unwrap_or_else(|| (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()))
754}
755
756fn compress_chunk_one(
759 chunk: &[u8],
760 codec_id: u8,
761 tunables: &limnifs_core::codec::CodecTunables,
762) -> (u8, std::sync::Arc<[u8]>) {
763 if codec_id == limnifs_core::codec::CODEC_STORE {
764 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
765 }
766 match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
767 Ok(c) if c.len() < chunk.len() => (codec_id, c.into()),
768 _ => (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()),
769 }
770}
771
772fn process_file(
776 pf: &PendingFile,
777 chunker: &FastCDC,
778 classifier: classifier::Classifier,
779 text_codec: u8,
780 binary_codec: u8,
781 tunables: &limnifs_core::codec::CodecTunables,
782 use_categorizers: bool,
783 skip_chunking: bool,
784 tournament: &TournamentSpec,
785 base_drop_index: Option<&std::collections::HashSet<[u8; 32]>>,
786) -> Result<ChunkedFileResult, WriteError> {
787 let file_len_estimate = std::fs::metadata(&pf.path)
798 .map(|m| m.len() as usize)
799 .unwrap_or(0);
800 let data: Vec<u8> = if file_len_estimate >= MMAP_READ_THRESHOLD {
801 let file = std::fs::File::open(&pf.path)?;
802 #[allow(unsafe_code)]
803 let mmap = unsafe { memmap2::Mmap::map(&file) }.map_err(WriteError::Io)?;
804 Vec::from(&mmap[..])
808 } else {
809 std::fs::read(&pf.path)?
810 };
811 let file_len = data.len();
812
813 if skip_chunking && file_len > INLINE_THRESHOLD {
820 let drop_id = hash_section(&data);
821 let class = classifier.classify(&data);
822 let preferred_codec = match class {
823 classifier::Class::Binary => binary_codec,
824 _ => text_codec,
825 };
826 let (codec_id, compressed): (u8, std::sync::Arc<[u8]>) =
827 match limnifs_core::codec::compress_with_tunables(preferred_codec, &data, tunables) {
828 Ok(c) if c.len() < data.len() => (preferred_codec, c.into()),
829 _ => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
830 };
831 return Ok(ChunkedFileResult {
832 drops: vec![(drop_id, data, compressed, codec_id)],
833 slices: vec![PendingSlice {
834 drop_id,
835 file_byte_start: 0,
836 file_byte_end: file_len as u64,
837 }],
838 });
839 }
840
841 if use_categorizers {
842 if let Some(cat) = file_categorizer::default_registry().categorize(&pf.path, &data) {
843 let needs_whole_file = matches!(
844 cat.codec_id,
845 limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
846 );
847 if needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE {
848 return process_whole_file_drop(pf, &data, cat, tunables);
849 }
850 }
851 }
852
853 let chunks = chunker.chunk_slice(&data);
854 let mut slices = Vec::with_capacity(chunks.len());
855 let mut file_offset: u64 = 0;
856 let mut seen_in_file: std::collections::HashSet<[u8; 32]> =
857 std::collections::HashSet::with_capacity(chunks.len());
858
859 let mut unique_chunks: Vec<(&[u8], [u8; 32])> = Vec::with_capacity(chunks.len());
862 for chunk in &chunks {
863 let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
864 let drop_id = hash_section(chunk);
865 slices.push(PendingSlice {
866 drop_id,
867 file_byte_start: file_offset,
868 file_byte_end: file_offset + chunk_len,
869 });
870 file_offset += chunk_len;
871 if seen_in_file.insert(drop_id) {
872 unique_chunks.push((chunk, drop_id));
873 }
874 }
875
876 use rayon::prelude::*;
888 thread_local! {
889 static COMPRESS_CACHE: std::cell::RefCell<std::collections::HashMap<[u8; 32], (u8, std::sync::Arc<[u8]>)>> =
890 std::cell::RefCell::new(std::collections::HashMap::new());
891 }
892 const COMPRESS_CACHE_MAX_ENTRIES: usize = 100_000;
893 let drops: Vec<RawDrop> = unique_chunks
894 .par_iter()
895 .map(|(chunk, drop_id)| {
896 if let Some(base) = base_drop_index {
899 if base.contains(drop_id) {
900 return (*drop_id, Vec::new(), Vec::new().into(), CODEC_REFERENCED);
901 }
902 }
903 let class = classifier.classify(chunk);
904 let cached = COMPRESS_CACHE.with(|c| {
907 c.borrow()
908 .get(drop_id)
909 .map(|(cid, comp)| (*cid, comp.clone()))
910 });
911 let (codec_id, compressed) = if let Some(c) = cached {
912 c
913 } else {
914 let new = compress_chunk_with_tournament(
915 chunk,
916 class,
917 text_codec,
918 binary_codec,
919 tunables,
920 tournament,
921 );
922 COMPRESS_CACHE.with(|c| {
924 let mut cache = c.borrow_mut();
925 if cache.len() < COMPRESS_CACHE_MAX_ENTRIES {
926 cache.insert(*drop_id, new.clone());
928 }
929 });
930 new
931 };
932 (*drop_id, chunk.to_vec(), compressed, codec_id)
933 })
934 .collect();
935
936 let _ = file_len;
937 Ok(ChunkedFileResult { drops, slices })
938}
939
940struct PendingDrop {
941 id: [u8; 32],
942 plaintext_len: u32,
948 compressed: std::sync::Arc<[u8]>,
949 codec: u8,
950 dict_id: u8,
954 plaintext: Option<Vec<u8>>,
959}
960
961impl PendingDrop {
962 fn len_in_window(&self) -> u32 {
966 u32::try_from(self.compressed.len()).expect("compressed fits u32")
967 }
968
969 fn plaintext_len_value(&self) -> u32 {
971 self.plaintext_len
972 }
973
974 fn slab_footprint(&self) -> usize {
977 48 + self.compressed.len()
978 }
979}
980
981struct PendingSlice {
986 drop_id: [u8; 32],
987 file_byte_start: u64,
988 file_byte_end: u64,
989}
990
991#[derive(Clone)]
994struct PendingFile {
995 inode_number: u64,
996 path: PathBuf,
997 mtime_ns: u64,
998 file_len: u64,
999}
1000
1001struct PendingInode {
1002 number: u64,
1003 mode: u32,
1004 mtime_ns: u64,
1005 content: PendingContent,
1006}
1007
1008enum PendingContent {
1009 Inline(Vec<u8>),
1010 DropBacked {
1011 file_len: u64,
1012 slices: Vec<PendingSlice>,
1013 },
1014 Directory(Vec<(String, u64, u8)>),
1015}
1016
1017struct DirNode {
1018 entries: Vec<(String, u64, u8)>,
1019 bytes: Vec<u8>,
1020 hash: [u8; 32],
1021}
1022
1023struct WriteContext {
1024 next_inode: u64,
1025 inodes: Vec<PendingInode>,
1026 dir_nodes: Vec<DirNode>,
1027 drops: Vec<PendingDrop>,
1028 drop_index: HashSet<[u8; 32]>,
1029 pending_files: Vec<PendingFile>,
1030 file_count: usize,
1031 dir_count: usize,
1032 root_inode_number: u64,
1033 chunker: FastCDC,
1034 classifier: classifier::Classifier,
1035 shared_inline_map: HashMap<[u8; 32], usize>,
1036 shared_inline_table: Vec<Vec<u8>>,
1037 profile_name: Option<String>,
1039 metadata_codec: u8,
1042 categorizers_disabled: bool,
1044 rw_mode: bool,
1046 auto_turnover: bool,
1048 collect_dict_samples: bool,
1051 dict_samples_by_class: HashMap<crate::classifier::Class, Vec<Vec<u8>>>,
1058 trained_dicts_by_class: HashMap<crate::classifier::Class, crate::dictionary::TrainedDictionary>,
1063 base_drop_index: Option<HashSet<[u8; 32]>>,
1069 base_root: Option<[u8; 32]>,
1074}
1075
1076impl WriteContext {
1077 const MAX_DICT_SAMPLES: usize = 1000;
1080
1081 fn new() -> Self {
1082 Self {
1083 next_inode: 1,
1084 inodes: Vec::new(),
1085 dir_nodes: Vec::new(),
1086 drops: Vec::new(),
1087 drop_index: HashSet::new(),
1088 pending_files: Vec::new(),
1089 file_count: 0,
1090 dir_count: 0,
1091 root_inode_number: 0,
1092 chunker: FastCDC::default(),
1093 classifier: classifier::Classifier,
1094 shared_inline_map: HashMap::new(),
1095 shared_inline_table: Vec::new(),
1096 profile_name: None,
1097 metadata_codec: limnifs_core::codec::CODEC_BROTLI,
1098 categorizers_disabled: false,
1099 rw_mode: false,
1100 auto_turnover: false,
1101 collect_dict_samples: false,
1102 dict_samples_by_class: HashMap::new(),
1103 trained_dicts_by_class: HashMap::new(),
1104 base_drop_index: None,
1105 base_root: None,
1106 }
1107 }
1108
1109 fn alloc_inode(&mut self) -> u64 {
1110 let n = self.next_inode;
1111 self.next_inode += 1;
1112 n
1113 }
1114
1115 fn build_shared_inline_table(&mut self) {
1119 let mut counts: HashMap<[u8; 32], usize> = HashMap::new();
1120 for inode in &self.inodes {
1121 if let PendingContent::Inline(data) = &inode.content {
1122 let h = hash_section(data);
1123 *counts.entry(h).or_default() += 1;
1124 }
1125 }
1126 for inode in &self.inodes {
1128 if let PendingContent::Inline(data) = &inode.content {
1129 let h = hash_section(data);
1130 if counts.get(&h).copied().unwrap_or(0) > 1
1131 && !self.shared_inline_map.contains_key(&h)
1132 {
1133 let idx = self.shared_inline_table.len();
1134 self.shared_inline_table.push(data.clone());
1135 self.shared_inline_map.insert(h, idx);
1136 }
1137 }
1138 }
1139 }
1140
1141 fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
1144 for (drop_id, plaintext, compressed, codec) in result.drops {
1145 if self.drop_index.insert(drop_id) {
1146 let retain_plaintext =
1152 self.collect_dict_samples && codec == limnifs_core::codec::CODEC_ZSTD;
1153 if retain_plaintext {
1154 let total: usize = self.dict_samples_by_class.values().map(Vec::len).sum();
1155 if total < Self::MAX_DICT_SAMPLES {
1156 let class = self.classifier.classify(&plaintext);
1157 self.dict_samples_by_class
1158 .entry(class)
1159 .or_default()
1160 .push(plaintext.clone());
1161 }
1162 }
1163 self.drops.push(PendingDrop {
1164 id: drop_id,
1165 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1166 compressed,
1167 codec,
1168 dict_id: limnifs_core::drop_record::NO_DICT,
1169 plaintext: if retain_plaintext {
1170 Some(plaintext)
1171 } else {
1172 None
1173 },
1174 });
1175 }
1176 }
1177 self.inodes.push(PendingInode {
1178 number: pf.inode_number,
1179 mode: 0o100_644,
1180 mtime_ns: pf.mtime_ns,
1181 content: PendingContent::DropBacked {
1182 file_len: pf.file_len,
1183 slices: result.slices,
1184 },
1185 });
1186 }
1187
1188 #[allow(dead_code)]
1199 fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
1200 let class = self.classifier.classify(plaintext);
1201 let (codec, compressed): (u8, std::sync::Arc<[u8]>) = match class {
1202 classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
1203 let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
1204 (limnifs_core::codec::CODEC_LZ4, c.into())
1205 }
1206 _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec().into()),
1207 };
1208 PendingDrop {
1209 id: drop_id,
1210 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1211 compressed,
1212 codec,
1213 dict_id: limnifs_core::drop_record::NO_DICT,
1214 plaintext: None,
1215 }
1216 }
1217
1218 fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
1219 let meta = std::fs::symlink_metadata(path)?;
1220 let file_type = meta.file_type();
1221 let mtime_ns = meta
1222 .modified()
1223 .ok()
1224 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1225 .map_or(0u128, |d| d.as_nanos());
1226 let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
1227
1228 if file_type.is_dir() {
1229 self.dir_count += 1;
1230 let inode_number = self.alloc_inode();
1231 let mut entries: Vec<(String, u64, u8)> = Vec::new();
1232
1233 for entry in std::fs::read_dir(path)? {
1234 let entry = entry?;
1235 let name = entry.file_name().to_string_lossy().into_owned();
1236 let child_path = entry.path();
1237 let child_inode = self.walk(&child_path)?;
1238 let child_meta = entry.metadata()?;
1239 let entry_type = if child_meta.is_dir() { 0x02 } else { 0x01 };
1240 entries.push((name, child_inode, entry_type));
1241 }
1242
1243 entries.sort_by(|a, b| a.0.cmp(&b.0));
1244 let dir_node = encode_dir_node(&entries);
1245 self.dir_nodes.push(dir_node);
1246 self.inodes.push(PendingInode {
1247 number: inode_number,
1248 mode: 0o040_755,
1249 mtime_ns,
1250 content: PendingContent::Directory(entries),
1251 });
1252 Ok(inode_number)
1253 } else if file_type.is_file() {
1254 self.file_count += 1;
1255 let inode_number = self.alloc_inode();
1256 let file_len = meta.len();
1257
1258 if file_len <= u64::try_from(INLINE_THRESHOLD).unwrap_or(u64::MAX) {
1259 let data = std::fs::read(path)?;
1260 self.inodes.push(PendingInode {
1261 number: inode_number,
1262 mode: 0o100_644,
1263 mtime_ns,
1264 content: PendingContent::Inline(data),
1265 });
1266 } else {
1267 self.pending_files.push(PendingFile {
1269 inode_number,
1270 path: path.to_path_buf(),
1271 mtime_ns,
1272 file_len,
1273 });
1274 }
1275 Ok(inode_number)
1276 } else {
1277 Err(WriteError::Io(std::io::Error::new(
1278 std::io::ErrorKind::Unsupported,
1279 format!("unsupported file type: {}", path.display()),
1280 )))
1281 }
1282 }
1283
1284 fn train_and_apply_dictionary(&mut self, dictionaries: &crate::config::DictionaryConfig) {
1299 let cleanup = |ctx: &mut Self| {
1300 for d in &mut ctx.drops {
1301 d.plaintext = None;
1302 }
1303 ctx.dict_samples_by_class.clear();
1304 };
1305
1306 if !dictionaries.enabled {
1307 cleanup(self);
1308 return;
1309 }
1310
1311 let target = usize::try_from(dictionaries.max_dict_size).unwrap_or(65_536);
1312 let min_class = usize::try_from(dictionaries.min_class_size).unwrap_or(0);
1313
1314 let text_classes = [
1318 crate::classifier::Class::Text,
1319 crate::classifier::Class::Code,
1320 crate::classifier::Class::Sparse,
1321 ];
1322 let binary_classes = [crate::classifier::Class::Binary];
1323
1324 let text_samples: Vec<&[u8]> = text_classes
1326 .iter()
1327 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1328 .map(Vec::as_slice)
1329 .collect();
1330 let trainer = crate::dictionary::TrainerKind::from_config_str(&dictionaries.trainer);
1331 if text_samples.len() >= min_class {
1332 if let Some(dict) =
1333 crate::dictionary::train_zstd_with_trainer(0, &text_samples, target, trainer)
1334 {
1335 self.trained_dicts_by_class
1336 .insert(crate::classifier::Class::Text, dict);
1337 }
1338 }
1339 let binary_samples: Vec<&[u8]> = binary_classes
1340 .iter()
1341 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1342 .map(Vec::as_slice)
1343 .collect();
1344 if binary_samples.len() >= min_class {
1345 if let Some(dict) =
1346 crate::dictionary::train_zstd_with_trainer(1, &binary_samples, target, trainer)
1347 {
1348 self.trained_dicts_by_class
1349 .insert(crate::classifier::Class::Binary, dict);
1350 }
1351 }
1352
1353 for d in self.drops.iter_mut() {
1356 if d.codec != limnifs_core::codec::CODEC_ZSTD {
1357 continue;
1358 }
1359 let Some(plaintext) = d.plaintext.clone() else {
1360 continue;
1361 };
1362 let class = self.classifier.classify(&plaintext);
1363 let dict_class = if text_classes.contains(&class) {
1364 crate::classifier::Class::Text
1365 } else if binary_classes.contains(&class) {
1366 crate::classifier::Class::Binary
1367 } else {
1368 continue;
1369 };
1370 let Some(dict) = self.trained_dicts_by_class.get(&dict_class) else {
1371 continue;
1372 };
1373 let Ok(dict_compressed) = dict.compress(&plaintext) else {
1374 continue;
1375 };
1376 if dict_compressed.len() < d.compressed.len() {
1377 d.compressed = dict_compressed.into();
1378 d.dict_id = dict.id;
1379 }
1380 }
1381
1382 cleanup(self);
1383 }
1384
1385 fn assemble(mut self) -> WriteArtifact {
1386 let inode_count = self.inodes.len();
1387 let dir_count = self.dir_count;
1388 let drop_count = self.drops.len();
1389
1390 let slabs = pack_slabs(&self.drops);
1396
1397 self.build_shared_inline_table();
1401
1402 let mut metadata_blob = Vec::new();
1403 metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
1404 for inode in &self.inodes {
1405 self.encode_inode(&mut metadata_blob, inode);
1406 }
1407 metadata_blob
1408 .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
1409 for node in &self.dir_nodes {
1410 metadata_blob.extend_from_slice(&node.bytes);
1411 }
1412 if !self.shared_inline_table.is_empty() {
1415 metadata_blob.extend_from_slice(
1416 &u32::try_from(self.shared_inline_table.len())
1417 .unwrap()
1418 .to_le_bytes(),
1419 );
1420 for entry in &self.shared_inline_table {
1421 let len = u32::try_from(entry.len()).expect("shared entry fits u32");
1422 metadata_blob.extend_from_slice(&len.to_le_bytes());
1423 metadata_blob.extend_from_slice(entry);
1424 }
1425 }
1426
1427 let uncompressed_len =
1434 u32::try_from(metadata_blob.len()).expect("metadata blob length fits u32");
1435 let metadata_hash = hash_section(&metadata_blob);
1436 let metadata_codec = self.metadata_codec;
1437 let metadata_quality = if metadata_blob.len() > METADATA_LARGE_BLOB_THRESHOLD {
1438 METADATA_LARGE_BLOB_QUALITY
1439 } else {
1440 METADATA_SMALL_BLOB_QUALITY
1441 };
1442 let compressed_blob = if metadata_codec == limnifs_core::codec::CODEC_BROTLI {
1443 limnifs_core::codec::compress_brotli_with_quality(&metadata_blob, metadata_quality)
1444 .unwrap_or_else(|_| metadata_blob.clone())
1445 } else {
1446 limnifs_core::codec::compress(metadata_codec, &metadata_blob)
1447 .unwrap_or_else(|_| metadata_blob.clone())
1448 };
1449 let (on_wire_codec, on_wire_blob) = if compressed_blob.len() < metadata_blob.len() {
1450 (metadata_codec, compressed_blob)
1451 } else {
1452 (limnifs_core::codec::CODEC_STORE, metadata_blob.clone())
1453 };
1454
1455 let (metadata_sidecar, inline_data, metadata_locator_count) =
1459 if on_wire_blob.len() > METADATA_EXTERNALIZE_THRESHOLD {
1460 let locator = "file:metadata.bin".to_owned();
1461 let sidecar = MetadataSidecar {
1462 bytes: on_wire_blob.clone(),
1463 locator,
1464 };
1465 (Some(sidecar), None, 1u32)
1466 } else {
1467 (None, Some(on_wire_blob.clone()), 0u32)
1468 };
1469
1470 let mut manifest = Vec::new();
1471
1472 let header_start = manifest.len();
1473 manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
1474 let header_end = manifest.len();
1475
1476 let flags_start = manifest.len();
1477 manifest.push(FEATURE_FLAGS_SECTION_VERSION);
1478 manifest.extend_from_slice(&0u32.to_le_bytes());
1479 let flags_end = manifest.len();
1480
1481 let meta_ref_start = manifest.len();
1484 manifest.push(METADATA_REFERENCE_SECTION_VERSION_2);
1485 manifest.extend_from_slice(&metadata_hash);
1486 manifest.extend_from_slice(&uncompressed_len.to_le_bytes());
1487 manifest.push(on_wire_codec);
1488 manifest.extend_from_slice(&metadata_locator_count.to_le_bytes());
1489 if let Some(sidecar) = &metadata_sidecar {
1490 let loc_bytes = sidecar.locator.as_bytes();
1491 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1492 manifest.extend_from_slice(&loc_len.to_le_bytes());
1493 manifest.extend_from_slice(loc_bytes);
1494 }
1495 match &inline_data {
1496 Some(blob) => {
1497 let inline_len = u32::try_from(blob.len()).expect("metadata fits u32");
1498 manifest.extend_from_slice(&inline_len.to_le_bytes());
1499 manifest.extend_from_slice(blob);
1500 }
1501 None => {
1502 manifest.extend_from_slice(&0u32.to_le_bytes());
1503 }
1504 }
1505 let meta_ref_end = manifest.len();
1506
1507 let slab_index_start = manifest.len();
1508 manifest.push(SLAB_INDEX_SECTION_VERSION);
1509 manifest.extend_from_slice(&u32::try_from(slabs.len()).unwrap().to_le_bytes());
1510 for slab in &slabs {
1511 manifest.extend_from_slice(&slab.id.to_bytes());
1512 manifest.extend_from_slice(&1u32.to_le_bytes());
1513 let loc_bytes = slab.locator.as_bytes();
1514 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1515 manifest.extend_from_slice(&loc_len.to_le_bytes());
1516 manifest.extend_from_slice(loc_bytes);
1517 }
1518 let slab_index_end = manifest.len();
1519
1520 let history_start = manifest.len();
1521 manifest.push(HISTORY_SECTION_VERSION);
1522 manifest.extend_from_slice(&1u32.to_le_bytes());
1523 manifest.push(0x01);
1524 manifest.extend_from_slice(&0u64.to_le_bytes());
1525 manifest.extend_from_slice(&0u32.to_le_bytes());
1526 manifest.extend_from_slice(&0u32.to_le_bytes());
1527 let history_end = manifest.len();
1528
1529 let profile_desc_start = manifest.len();
1534 if let Some(ref name) = self.profile_name {
1535 let desc = limnifs_core::profile_descriptor::ProfileDescriptor {
1536 version: limnifs_core::profile_descriptor::PROFILE_DESCRIPTOR_SECTION_VERSION,
1537 profile_name: Some(name.clone()),
1538 blake3_hashing: true,
1539 cross_file_dedup: true,
1540 content_classification: !self.categorizers_disabled,
1541 integrity_verify: true,
1542 read_write: self.rw_mode,
1543 auto_turnover: self.auto_turnover,
1544 };
1545 limnifs_core::profile_descriptor::encode_profile_descriptor(&desc, &mut manifest);
1546 }
1547 let profile_desc_end = manifest.len();
1548
1549 if !self.trained_dicts_by_class.is_empty() {
1555 let dicts: Vec<_> = self
1556 .trained_dicts_by_class
1557 .values()
1558 .map(|d| limnifs_core::dictionary_section::Dictionary {
1559 codec_id: d.codec,
1560 class_id: d.id,
1561 data: d.content.clone(),
1562 })
1563 .collect();
1564 let section = limnifs_core::dictionary_section::DictionarySection {
1565 version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
1566 dicts,
1567 };
1568 limnifs_core::dictionary_section::encode_dictionary_section(§ion, &mut manifest);
1569 }
1570
1571 let dictionary_end = manifest.len();
1572
1573 let delta_linkage_hash = if let Some(base_root) = self.base_root {
1579 let delta_start = manifest.len();
1580 manifest.push(limnifs_core::delta_linkage::DELTA_LINKAGE_SECTION_VERSION);
1586 manifest.extend_from_slice(&base_root);
1587 manifest.extend_from_slice(&0u32.to_le_bytes());
1588 hash_section(&manifest[delta_start..])
1589 } else {
1590 hash_empty_section()
1591 };
1592 let _ = dictionary_end;
1593
1594 let hashes = SectionHashes {
1595 metadata: metadata_hash,
1596 format_header: hash_section(&manifest[header_start..header_end]),
1597 feature_flags: hash_section(&manifest[flags_start..flags_end]),
1598 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
1599 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
1600 crypto_params: hash_empty_section(),
1601 ec_params: hash_empty_section(),
1602 dms_policy: hash_empty_section(),
1603 delta_linkage: delta_linkage_hash,
1604 history: hash_section(&manifest[history_start..history_end]),
1605 };
1617 let merkle_root = compute_merkle_root(&hashes);
1618
1619 WriteArtifact {
1620 bytes: manifest,
1621 merkle_root,
1622 slabs,
1623 metadata_sidecar,
1624 inode_count,
1625 file_count: self.file_count,
1626 dir_count,
1627 drop_count,
1628 root_inode_number: self.root_inode_number,
1629 }
1630 }
1631
1632 fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
1633 out.extend_from_slice(&inode.number.to_le_bytes());
1634 out.extend_from_slice(&inode.mode.to_le_bytes());
1635 out.extend_from_slice(&0u32.to_le_bytes());
1636 out.extend_from_slice(&0u32.to_le_bytes());
1637 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1638 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1639 out.extend_from_slice(&1u32.to_le_bytes());
1640 match &inode.content {
1641 PendingContent::Inline(data) => {
1642 let h = hash_section(data);
1643 if let Some(&idx) = self.shared_inline_map.get(&h) {
1644 out.push(INODE_FLAG_INLINE_DATA | INODE_FLAG_SHARED_INLINE);
1646 out.extend_from_slice(&(idx as u32).to_le_bytes());
1647 } else {
1648 out.push(INODE_FLAG_INLINE_DATA);
1649 let len = u32::try_from(data.len()).expect("data fits u32");
1650 out.extend_from_slice(&len.to_le_bytes());
1651 out.extend_from_slice(data);
1652 }
1653 }
1654 PendingContent::DropBacked { file_len, slices } => {
1655 out.push(0x00);
1656 let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
1657 out.extend_from_slice(&slice_count.to_le_bytes());
1658 for slice in slices {
1659 out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
1660 out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
1661 out.extend_from_slice(&slice.drop_id);
1662 out.extend_from_slice(&0u32.to_le_bytes());
1664 let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
1668 .expect("slice range fits u32");
1669 out.extend_from_slice(&drop_byte_len.to_le_bytes());
1670 }
1671 let _ = file_len;
1672 }
1673 PendingContent::Directory(entries) => {
1674 out.push(0x00);
1675 let node = self
1676 .dir_nodes
1677 .iter()
1678 .find(|n| n.entries == *entries)
1679 .expect("directory node must exist");
1680 out.extend_from_slice(&node.hash);
1681 }
1682 }
1683 }
1684}
1685
1686fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
1687 let mut bytes = Vec::new();
1688 bytes.push(1u8);
1689 let count = u32::try_from(entries.len()).expect("entry count fits u32");
1690 bytes.extend_from_slice(&count.to_le_bytes());
1691 for (name, inode_number, entry_type) in entries {
1692 let name_bytes = name.as_bytes();
1693 let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
1694 bytes.extend_from_slice(&name_len.to_le_bytes());
1695 bytes.extend_from_slice(name_bytes);
1696 bytes.extend_from_slice(&inode_number.to_le_bytes());
1697 bytes.push(*entry_type);
1698 }
1699 let hash = hash_section(&bytes);
1700 DirNode {
1701 entries: entries.to_vec(),
1702 bytes,
1703 hash,
1704 }
1705}
1706
1707fn pack_slabs(drops: &[PendingDrop]) -> Vec<SlabArtifact> {
1716 let local_drops: Vec<&PendingDrop> = drops
1721 .iter()
1722 .filter(|d| d.codec != limnifs_core::codec::CODEC_REFERENCED)
1723 .collect();
1724 if local_drops.is_empty() {
1725 return Vec::new();
1726 }
1727
1728 let max_content = MAX_SLAB_TOTAL_BYTES.saturating_sub(SLAB_HEADER_LEN);
1729
1730 let mut slab_groups: Vec<Vec<&PendingDrop>> = Vec::new();
1735 let mut current: Vec<&PendingDrop> = Vec::new();
1736 let mut current_size: usize = 0;
1737
1738 for drop in &local_drops {
1739 let footprint = drop.slab_footprint();
1740 if !current.is_empty() && current_size + footprint > max_content {
1741 slab_groups.push(std::mem::take(&mut current));
1742 current_size = 0;
1743 }
1744 current.push(*drop);
1745 current_size += footprint;
1746 }
1747 if !current.is_empty() {
1748 slab_groups.push(current);
1749 }
1750
1751 use rayon::prelude::*;
1757 slab_groups
1758 .par_iter()
1759 .enumerate()
1760 .map(|(ordinal, group)| {
1761 let ordinal_u64 = u64::try_from(ordinal).expect("slab count fits u64");
1762 encode_slab(ordinal_u64, group)
1763 })
1764 .collect()
1765}
1766
1767fn encode_slab(ordinal: u64, drops: &[&PendingDrop]) -> SlabArtifact {
1771 const DROP_RECORD_LEN: usize = 49;
1777 let mut drop_records = Vec::with_capacity(drops.len() * DROP_RECORD_LEN);
1778 let mut solid_window = Vec::new();
1779 let mut drop_ids = Vec::with_capacity(drops.len());
1780 let mut offset_in_window: u32 = 0;
1781
1782 for drop in drops {
1783 let plaintext_len = drop.plaintext_len_value();
1784 let window_len = drop.len_in_window();
1785 drop_records.extend_from_slice(&drop.id);
1786 drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
1787 drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
1789 drop_records.push(0x00); drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
1791 drop_records.extend_from_slice(&window_len.to_le_bytes());
1792 drop_records.push(drop.dict_id); solid_window.extend_from_slice(&drop.compressed);
1794 drop_ids.push(drop.id);
1795 offset_in_window = offset_in_window
1796 .checked_add(window_len)
1797 .expect("slab window size fits u32");
1798 }
1799
1800 let slab_content = [&drop_records[..], &solid_window[..]].concat();
1801 let slab_hash = hash_section(&slab_content);
1802 let slab_id = SlabId::new(ordinal, slab_hash);
1803
1804 let total_length = SLAB_HEADER_LEN + slab_content.len();
1805 let mut slab_bytes = Vec::with_capacity(total_length);
1806 slab_bytes.extend_from_slice(b"LIM1");
1807 slab_bytes.extend_from_slice(&1u16.to_le_bytes());
1808 slab_bytes.extend_from_slice(&slab_id.to_bytes());
1809 slab_bytes.extend_from_slice(
1810 &u64::try_from(total_length)
1811 .unwrap_or(u64::MAX)
1812 .to_le_bytes(),
1813 );
1814 slab_bytes.push(0x00);
1815 slab_bytes.push(0x00);
1816 slab_bytes.extend_from_slice(&slab_content);
1817
1818 let locator = format!("file:slab-{ordinal}.bin");
1819
1820 SlabArtifact {
1821 id: slab_id,
1822 bytes: slab_bytes,
1823 locator,
1824 drop_ids,
1825 }
1826}
1827
1828#[cfg(test)]
1829fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
1830 let mut state = seed;
1831 let mut out = Vec::with_capacity(count);
1832 for _ in 0..count {
1833 state = state
1834 .wrapping_mul(6_364_136_223_846_793_005)
1835 .wrapping_add(1_442_695_040_888_963_407);
1836 out.push(u8::try_from(state >> 56).expect("fits u8"));
1837 }
1838 out
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843 use super::*;
1844 use limnifs_core::ManifestCursor;
1845
1846 #[test]
1847 fn write_stream_packs_single_named_stream() {
1848 let temp = std::env::temp_dir().join(format!(
1852 "limnifs-write-stream-test-{}-{}",
1853 std::process::id(),
1854 std::time::SystemTime::now()
1855 .duration_since(std::time::UNIX_EPOCH)
1856 .unwrap()
1857 .as_nanos()
1858 ));
1859 std::fs::create_dir_all(&temp).expect("create temp dir");
1860
1861 let content = b"stream test content line\n".repeat(10_000); let cursor = std::io::Cursor::new(content.clone());
1863 let config = WriteConfig::default_v0_1();
1864 let artifact = write_stream("streamed.txt", cursor, &config).expect("write_stream");
1865
1866 assert!(!artifact.bytes.is_empty(), "manifest bytes non-empty");
1867 assert!(!artifact.slabs.is_empty(), "at least one slab produced");
1868 let total_drop_bytes: usize = artifact.slabs.iter().map(|s| s.bytes.len()).sum();
1873 assert!(total_drop_bytes > 0, "drops non-empty");
1874
1875 let _ = std::fs::remove_dir_all(&temp);
1876 }
1877
1878 #[test]
1879 fn write_layer_references_base_drops() {
1880 let temp = std::env::temp_dir().join(format!(
1886 "limnifs-write-layer-test-{}-{}",
1887 std::process::id(),
1888 std::time::SystemTime::now()
1889 .duration_since(std::time::UNIX_EPOCH)
1890 .unwrap()
1891 .as_nanos()
1892 ));
1893 std::fs::create_dir_all(&temp).expect("create temp dir");
1894
1895 let base_dir = temp.join("base");
1897 std::fs::create_dir_all(&base_dir).expect("base dir");
1898 let text = b"layer test content line\n".repeat(50_000); std::fs::write(base_dir.join("shared.txt"), &text).expect("write shared");
1900 std::fs::write(base_dir.join("base-only.txt"), b"only in base").expect("write base-only");
1901
1902 let config = WriteConfig::default_v0_1();
1903 let base_artifact = write_directory_with_config(&base_dir, &config).expect("base");
1904
1905 let base_manifest = temp.join("base.lim");
1906 std::fs::write(&base_manifest, &base_artifact.bytes).expect("write base manifest");
1907 for slab in &base_artifact.slabs {
1908 let slab_name = slab.locator.strip_prefix("file:").unwrap_or(&slab.locator);
1909 std::fs::write(temp.join(slab_name), &slab.bytes).expect("write base slab");
1910 }
1911
1912 let layer_dir = temp.join("layer");
1914 std::fs::create_dir_all(&layer_dir).expect("layer dir");
1915 std::fs::write(layer_dir.join("shared.txt"), &text).expect("write shared in layer");
1916 std::fs::write(layer_dir.join("new.txt"), b"fresh content in layer").expect("write new");
1917
1918 let layer_artifact = write_layer(&base_manifest, &layer_dir, &config).expect("layer");
1919
1920 let layer_slab_bytes: usize = layer_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
1923 let base_slab_bytes: usize = base_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
1924 assert!(
1925 layer_slab_bytes < base_slab_bytes / 4,
1926 "layer slabs ({}) should be much smaller than base ({}) — layering failed",
1927 layer_slab_bytes,
1928 base_slab_bytes
1929 );
1930
1931 let base_root = base_artifact.merkle_root.as_bytes();
1934 assert!(
1935 layer_artifact
1936 .bytes
1937 .windows(32)
1938 .any(|w| w == base_root.as_slice()),
1939 "layer manifest must contain base's ManifestRoot bytes"
1940 );
1941
1942 let _ = std::fs::remove_dir_all(&temp);
1943 }
1944
1945 #[test]
1946 fn tournament_short_circuits_on_highly_compressible_chunk() {
1947 let chunk = b"hello world ".repeat(500);
1950 let tunables = limnifs_core::codec::CodecTunables::default();
1951 let tournament = TournamentSpec {
1952 codec_ids: vec![
1953 limnifs_core::codec::CODEC_LZ4,
1954 limnifs_core::codec::CODEC_BROTLI,
1955 ],
1956 min_size: 16,
1957 skip_for_binary: false,
1958 short_circuit_permille: 250,
1959 };
1960 let (codec_id, compressed) = compress_chunk_with_tournament(
1961 &chunk,
1962 classifier::Class::Text,
1963 limnifs_core::codec::CODEC_BROTLI,
1964 limnifs_core::codec::CODEC_LZ4,
1965 &tunables,
1966 &tournament,
1967 );
1968 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
1969 assert!(compressed.len() < chunk.len());
1970 }
1971
1972 #[test]
1973 fn tournament_runs_all_codecs_when_short_circuit_disabled() {
1974 let chunk = b"hello world ".repeat(500);
1980 let tunables = limnifs_core::codec::CodecTunables::default();
1981 let tournament = TournamentSpec {
1982 codec_ids: vec![
1983 limnifs_core::codec::CODEC_LZ4,
1984 limnifs_core::codec::CODEC_BROTLI,
1985 limnifs_core::codec::CODEC_ZSTD,
1986 ],
1987 min_size: 16,
1988 skip_for_binary: false,
1989 short_circuit_permille: 0,
1990 };
1991 let (codec_id, compressed) = compress_chunk_with_tournament(
1992 &chunk,
1993 classifier::Class::Text,
1994 limnifs_core::codec::CODEC_BROTLI,
1995 limnifs_core::codec::CODEC_LZ4,
1996 &tunables,
1997 &tournament,
1998 );
1999 assert!(
2003 codec_id == limnifs_core::codec::CODEC_ZSTD
2004 || codec_id == limnifs_core::codec::CODEC_BROTLI,
2005 "expected ZSTD or Brotli to win, got codec {codec_id}"
2006 );
2007 assert!(compressed.len() < chunk.len());
2008 }
2009
2010 #[test]
2011 fn tournament_skips_for_binary_when_configured() {
2012 let chunk = vec![0u8; 4096];
2013 let tunables = limnifs_core::codec::CodecTunables::default();
2014 let tournament = TournamentSpec {
2015 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2016 min_size: 16,
2017 skip_for_binary: true,
2018 short_circuit_permille: 250,
2019 };
2020 let (codec_id, _compressed) = compress_chunk_with_tournament(
2021 &chunk,
2022 classifier::Class::Binary,
2023 limnifs_core::codec::CODEC_BROTLI,
2024 limnifs_core::codec::CODEC_LZ4,
2025 &tunables,
2026 &tournament,
2027 );
2028 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2030 }
2031
2032 #[test]
2033 fn tournament_small_chunk_uses_preferred_codec() {
2034 let chunk = b"tiny";
2035 let tunables = limnifs_core::codec::CodecTunables::default();
2036 let tournament = TournamentSpec {
2037 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2038 min_size: 1024,
2039 skip_for_binary: false,
2040 short_circuit_permille: 0,
2041 };
2042 let (codec_id, _compressed) = compress_chunk_with_tournament(
2043 chunk,
2044 classifier::Class::Text,
2045 limnifs_core::codec::CODEC_BROTLI,
2046 limnifs_core::codec::CODEC_LZ4,
2047 &tunables,
2048 &tournament,
2049 );
2050 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2052 }
2053
2054 #[test]
2055 fn tournament_falls_back_to_store_when_no_codec_compresses() {
2056 let chunk = pseudo_random_bytes(42, 4096);
2060 let tunables = limnifs_core::codec::CodecTunables::default();
2061 let tournament = TournamentSpec {
2062 codec_ids: vec![limnifs_core::codec::CODEC_LZ4],
2063 min_size: 16,
2064 skip_for_binary: false,
2065 short_circuit_permille: 0,
2066 };
2067 let (codec_id, compressed) = compress_chunk_with_tournament(
2068 &chunk,
2069 classifier::Class::Binary,
2070 limnifs_core::codec::CODEC_BROTLI,
2071 limnifs_core::codec::CODEC_LZ4,
2072 &tunables,
2073 &tournament,
2074 );
2075 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2076 assert_eq!(compressed.len(), chunk.len());
2077 }
2078
2079 #[test]
2080 fn dictionaries_enabled_emits_dictionary_section_when_enough_samples() {
2081 let temp = std::env::temp_dir().join(format!(
2086 "limnifs-write-test-{}-dict-{}",
2087 std::process::id(),
2088 std::time::SystemTime::now()
2089 .duration_since(std::time::UNIX_EPOCH)
2090 .map(|d| d.as_nanos() as u64)
2091 .unwrap_or(0),
2092 ));
2093 let _ = std::fs::remove_dir_all(&temp);
2094 std::fs::create_dir_all(&temp).expect("mkdir");
2095
2096 for i in 0..200 {
2099 let content = format!(
2101 "function test_case_{i}() {{ return constant + {i}; }}\n\
2102 // shared comment line {i}\n\
2103 struct Foo {{ x: i32 }} // type {i}\n"
2104 )
2105 .repeat(5);
2106 let path = temp.join(format!("file_{i:04}.txt"));
2107 std::fs::write(&path, content.as_bytes()).expect("write");
2108 }
2109
2110 let mut config = crate::profile::balanced();
2111 config.defaults.text_codec = "zstd".into();
2113 config.defaults.metadata_codec = "zstd".into();
2117 config.dictionaries.enabled = true;
2118 config.dictionaries.min_class_size = 50;
2119 config.dictionaries.max_dict_size = 8192;
2120
2121 let artifact = write_directory_with_config(&temp, &config).expect("write");
2122 std::fs::remove_dir_all(&temp).ok();
2123
2124 let mut cursor = ManifestCursor::new(&artifact.bytes);
2129 let _ = limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2130 let _ = limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2131 let _ = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta_ref");
2132 let _ = limnifs_core::parse_slab_index(&mut cursor).expect("slab_index");
2133 let _ = limnifs_core::parse_history(&mut cursor).expect("history");
2134 let _remaining = cursor.remaining_len();
2137 }
2138
2139 #[test]
2140 fn write_empty_directory() {
2141 let temp =
2142 std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
2143 std::fs::create_dir_all(&temp).expect("create temp dir");
2144 let artifact = write_directory(&temp).expect("write succeeds");
2145 std::fs::remove_dir_all(&temp).ok();
2146 assert!(artifact.inode_count >= 1);
2147 assert_eq!(artifact.file_count, 0);
2148 assert_eq!(artifact.dir_count, 1);
2149 assert!(artifact.slabs.is_empty());
2150 }
2151
2152 #[test]
2153 fn write_small_file_inline() {
2154 let temp =
2155 std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
2156 std::fs::create_dir_all(&temp).expect("create temp dir");
2157 std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
2158 let artifact = write_directory(&temp).expect("write succeeds");
2159 std::fs::remove_dir_all(&temp).ok();
2160 assert_eq!(artifact.file_count, 1);
2161 assert!(artifact.slabs.is_empty());
2162 assert_eq!(artifact.drop_count, 0);
2163 }
2164
2165 #[test]
2166 fn write_large_file_uses_slab() {
2167 let temp =
2168 std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
2169 std::fs::create_dir_all(&temp).expect("create temp dir");
2170 let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
2171 std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
2172 let artifact = write_directory(&temp).expect("write succeeds");
2173 std::fs::remove_dir_all(&temp).ok();
2174 assert_eq!(artifact.drop_count, 1);
2175 assert_eq!(artifact.slabs.len(), 1);
2176 }
2177
2178 #[test]
2179 fn write_mixed_inline_and_large() {
2180 let temp =
2181 std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
2182 std::fs::create_dir_all(&temp).expect("create temp dir");
2183 std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
2184 std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
2185 .expect("write large");
2186 let artifact = write_directory(&temp).expect("write succeeds");
2187 std::fs::remove_dir_all(&temp).ok();
2188 assert_eq!(artifact.file_count, 2);
2189 assert_eq!(artifact.drop_count, 1);
2190 assert_eq!(artifact.slabs.len(), 1);
2191 }
2192
2193 #[test]
2194 fn deduplicates_identical_large_files() {
2195 let temp =
2196 std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
2197 std::fs::create_dir_all(&temp).expect("create temp dir");
2198 let data = vec![0x77u8; INLINE_THRESHOLD + 10];
2199 std::fs::write(temp.join("a.bin"), &data).expect("write a");
2200 std::fs::write(temp.join("b.bin"), &data).expect("write b");
2201 let artifact = write_directory(&temp).expect("write succeeds");
2202 std::fs::remove_dir_all(&temp).ok();
2203 assert_eq!(artifact.drop_count, 1);
2204 }
2205
2206 #[test]
2207 fn write_and_verify_roundtrip() {
2208 let temp = std::env::temp_dir().join(format!(
2209 "limnifs-write-test-{}-roundtrip",
2210 std::process::id()
2211 ));
2212 std::fs::create_dir_all(&temp).expect("create temp dir");
2213 std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
2214 std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
2215 std::fs::create_dir_all(temp.join("sub")).expect("create sub");
2216 std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
2217 let artifact = write_directory(&temp).expect("write succeeds");
2218 std::fs::remove_dir_all(&temp).ok();
2219 assert_eq!(artifact.file_count, 3);
2220 assert_eq!(artifact.dir_count, 2);
2221
2222 let mut cursor = ManifestCursor::new(&artifact.bytes);
2223 limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2224 limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2225 let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
2226 assert!(meta_ref.is_inlined());
2227 let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
2228 assert_eq!(slab_index.len(), 0);
2229 limnifs_core::parse_history(&mut cursor).expect("history");
2230 }
2231
2232 #[test]
2233 fn write_deterministic() {
2234 let temp =
2235 std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
2236 std::fs::create_dir_all(&temp).expect("create temp dir");
2237 std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
2238
2239 let a1 = write_directory(&temp).expect("first write");
2240 let a2 = write_directory(&temp).expect("second write");
2241 std::fs::remove_dir_all(&temp).ok();
2242
2243 assert_eq!(a1.bytes, a2.bytes);
2244 assert_eq!(a1.merkle_root, a2.merkle_root);
2245 }
2246
2247 #[test]
2248 fn slab_parses_correctly() {
2249 let temp =
2250 std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
2251 std::fs::create_dir_all(&temp).expect("create temp dir");
2252 std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
2253 .expect("write big");
2254 let artifact = write_directory(&temp).expect("write succeeds");
2255 std::fs::remove_dir_all(&temp).ok();
2256
2257 let slab_bytes = &artifact.slabs[0].bytes;
2258 let mut cursor = ManifestCursor::new(slab_bytes);
2259 let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
2260 assert_eq!(slab_header.format_version, 1);
2261 assert!(!slab_header.is_sealed());
2262 assert!(!slab_header.has_erasure_coding());
2263
2264 let drop_record =
2265 limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
2266 assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
2267 }
2268
2269 #[test]
2270 fn fastcdc_produces_multiple_chunks_for_large_files() {
2271 let temp = std::env::temp_dir().join(format!(
2274 "limnifs-write-test-{}-cdc-multi",
2275 std::process::id()
2276 ));
2277 std::fs::create_dir_all(&temp).expect("create temp dir");
2278 let data = pseudo_random_bytes(42, 1024 * 1024);
2279 std::fs::write(temp.join("big.bin"), &data).expect("write big");
2280 let artifact = write_directory(&temp).expect("write succeeds");
2281 std::fs::remove_dir_all(&temp).ok();
2282 assert!(
2283 artifact.drop_count > 1,
2284 "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
2285 artifact.drop_count
2286 );
2287 }
2288
2289 #[test]
2290 fn fastcdc_deduplicates_shared_substrings() {
2291 let temp = std::env::temp_dir().join(format!(
2295 "limnifs-write-test-{}-cdc-dedup",
2296 std::process::id()
2297 ));
2298 std::fs::create_dir_all(&temp).expect("create temp dir");
2299 let shared = pseudo_random_bytes(7, 512 * 1024);
2300 let mut a = Vec::with_capacity(shared.len() + 1024);
2301 a.extend_from_slice(&pseudo_random_bytes(1, 1024));
2302 a.extend_from_slice(&shared);
2303 let mut b = Vec::with_capacity(shared.len() + 2048);
2304 b.extend_from_slice(&pseudo_random_bytes(2, 2048));
2305 b.extend_from_slice(&shared);
2306 std::fs::write(temp.join("a.bin"), &a).expect("write a");
2307 std::fs::write(temp.join("b.bin"), &b).expect("write b");
2308
2309 let temp_a = std::env::temp_dir().join(format!(
2311 "limnifs-write-test-{}-cdc-dedup-a",
2312 std::process::id()
2313 ));
2314 std::fs::create_dir_all(&temp_a).expect("create temp_a");
2315 std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
2316 let artifact_a = write_directory(&temp_a).expect("a writes");
2317 std::fs::remove_dir_all(&temp_a).ok();
2318
2319 let temp_b = std::env::temp_dir().join(format!(
2320 "limnifs-write-test-{}-cdc-dedup-b",
2321 std::process::id()
2322 ));
2323 std::fs::create_dir_all(&temp_b).expect("create temp_b");
2324 std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
2325 let artifact_b = write_directory(&temp_b).expect("b writes");
2326 std::fs::remove_dir_all(&temp_b).ok();
2327
2328 let artifact_both = write_directory(&temp).expect("both write");
2329 std::fs::remove_dir_all(&temp).ok();
2330
2331 let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
2332 assert!(
2333 artifact_both.drop_count < sum_alone,
2334 "expected dedup win: both together = {} drops, sum alone = {} drops",
2335 artifact_both.drop_count,
2336 sum_alone
2337 );
2338 }
2339
2340 #[test]
2341 fn slab_splits_when_content_exceeds_ceiling() {
2342 let temp =
2348 std::env::temp_dir().join(format!("limnifs-write-test-{}-split", std::process::id()));
2349 std::fs::create_dir_all(&temp).expect("create temp dir");
2350 for i in 0..7u32 {
2351 let data = pseudo_random_bytes(u64::from(i), 10 * 1024 * 1024);
2353 std::fs::write(temp.join(format!("big-{i}.bin")), &data).expect("write big");
2354 }
2355 let artifact = write_directory(&temp).expect("write succeeds");
2356 std::fs::remove_dir_all(&temp).ok();
2357
2358 assert!(
2360 artifact.slabs.len() >= 2,
2361 "expected at least 2 slabs for 70 MiB of incompressible data, got {}",
2362 artifact.slabs.len()
2363 );
2364 for slab in &artifact.slabs {
2365 assert!(
2366 slab.bytes.len() <= MAX_SLAB_TOTAL_BYTES,
2367 "slab {} is {} bytes (> {} ceiling)",
2368 slab.id.ordinal,
2369 slab.bytes.len(),
2370 MAX_SLAB_TOTAL_BYTES,
2371 );
2372 }
2373 let total_drop_ids: usize = artifact.slabs.iter().map(|s| s.drop_ids.len()).sum();
2375 assert_eq!(
2376 total_drop_ids, artifact.drop_count,
2377 "drop_ids count across slabs must match WriteArtifact.drop_count",
2378 );
2379 }
2380}