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;
30use file_categorizer::FileCategorizer;
31pub mod flatten;
32pub mod progress;
33pub mod rw;
34#[cfg(feature = "sparse-index")]
35pub mod sparse_index;
36pub mod stream;
37pub mod turnover;
38
39pub use config::{
40 profile, CategorizerConfig, ChunkingConfig, CodecRegistry, CodecTunables, Defaults,
41 DictionaryConfig, EncryptionConfig, TournamentConfig, WriteConfig,
42};
43
44use std::collections::{HashMap, HashSet};
45use std::path::{Path, PathBuf};
46
47use crate::chunker::{Chunker, ParallelFastCDC};
48use limnifs_core::codec::CODEC_REFERENCED;
49use limnifs_core::dictionary_section::parse_dictionary_section;
50use limnifs_core::slab_store::SlabStore;
51use limnifs_core::{
52 compute_merkle_root, hash_empty_section, hash_section, parse_manifest_header, parse_slab_index,
53 ManifestCursor, ManifestHeader, SectionHashes, FEATURE_FLAGS_SECTION_VERSION,
54 HISTORY_SECTION_VERSION, INODE_FLAG_INLINE_DATA, INODE_FLAG_SHARED_INLINE,
55 METADATA_REFERENCE_SECTION_VERSION_2, SLAB_INDEX_SECTION_VERSION,
56};
57use limnifs_format::{ManifestRoot, SlabId};
58
59pub const INLINE_THRESHOLD: usize = 4096;
62
63pub const MMAP_READ_THRESHOLD: usize = 1024 * 1024;
69
70pub const WHOLE_FILE_MAX_SIZE: usize = 64 * 1024 * 1024;
75
76pub const MAX_SLAB_TOTAL_BYTES: usize = 60 * 1024 * 1024;
81
82const SLAB_HEADER_LEN: usize = 56;
86
87pub const METADATA_EXTERNALIZE_THRESHOLD: usize =
95 limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES as usize - 24 * 1024;
96
97pub const METADATA_LARGE_BLOB_THRESHOLD: usize = 256 * 1024;
102
103pub const METADATA_SMALL_BLOB_QUALITY: i32 = 5;
106
107pub const METADATA_LARGE_BLOB_QUALITY: i32 = 2;
112
113#[derive(Clone, Debug)]
116pub struct SlabArtifact {
117 pub id: SlabId,
118 pub bytes: Vec<u8>,
119 pub locator: String,
120 pub drop_ids: Vec<[u8; 32]>,
124}
125
126#[derive(Clone, Debug)]
130pub struct MetadataSidecar {
131 pub bytes: Vec<u8>,
132 pub locator: String,
133}
134
135#[derive(Clone, Debug)]
137pub struct WriteArtifact {
138 pub bytes: Vec<u8>,
139 pub merkle_root: ManifestRoot,
140 pub slabs: Vec<SlabArtifact>,
143 pub metadata_sidecar: Option<MetadataSidecar>,
147 pub inode_count: usize,
148 pub file_count: usize,
149 pub dir_count: usize,
150 pub drop_count: usize,
151 pub root_inode_number: u64,
156}
157
158impl WriteArtifact {
159 #[must_use]
163 pub fn slab_bytes(&self) -> Option<&[u8]> {
164 if self.slabs.len() == 1 {
165 Some(&self.slabs[0].bytes)
166 } else {
167 None
168 }
169 }
170
171 #[must_use]
173 pub fn slab_locator(&self) -> Option<&str> {
174 if self.slabs.len() == 1 {
175 Some(&self.slabs[0].locator)
176 } else {
177 None
178 }
179 }
180}
181
182#[derive(Debug)]
184pub enum WriteError {
185 Io(std::io::Error),
186 UnsupportedFileType {
191 path: PathBuf,
192 kind: String,
193 },
194}
195
196impl std::fmt::Display for WriteError {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 match self {
199 Self::Io(e) => write!(f, "I/O error: {e}"),
200 Self::UnsupportedFileType { path, kind } => write!(
201 f,
202 "unsupported file type ({kind}): {} — limnifs stores files, \
203 directories, and symlinks; remove the entry or file an issue \
204 if you need it carried",
205 path.display()
206 ),
207 }
208 }
209}
210
211impl std::error::Error for WriteError {}
212
213impl From<std::io::Error> for WriteError {
214 fn from(e: std::io::Error) -> Self {
215 Self::Io(e)
216 }
217}
218
219pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
233 write_directory_with_config(root, &WriteConfig::default_v0_1())
234}
235
236pub fn write_stream<R: std::io::Read>(
254 name: &str,
255 mut reader: R,
256 config: &WriteConfig,
257) -> Result<WriteArtifact, WriteError> {
258 let mut writer = crate::stream::StreamWriter::new(config)?;
259 writer.add_file(name, 0, 0o644, &mut reader)?;
260 writer.finish()
261}
262
263pub fn write_layer(
304 base_image: &Path,
305 root: &Path,
306 config: &WriteConfig,
307) -> Result<WriteArtifact, WriteError> {
308 let base_root = load_base_drop_index(base_image)?.1;
310 let base_drop_index: std::sync::Arc<dyn BaseDropSet> = {
311 #[cfg(feature = "sparse-index")]
312 {
313 match SparseBackedBaseIndex::open(base_image) {
314 Some(idx) => std::sync::Arc::new(idx),
315 None => std::sync::Arc::new(load_base_drop_index(base_image)?.0),
316 }
317 }
318 #[cfg(not(feature = "sparse-index"))]
319 {
320 std::sync::Arc::new(load_base_drop_index(base_image)?.0)
321 }
322 };
323
324 let mut ctx = WriteContext::new();
325 ctx.chunker = chunker_from_config(config)?;
326 ctx.base_dictionaries = if config.dictionaries.enabled {
329 load_base_dictionary_section(base_image)?.map(crate::dictionary::adopt_from_section)
330 } else {
331 None
332 };
333 ctx.categorizers_disabled = config.categorizers.is_empty();
334 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
335 ctx.auto_turnover = config.turnover_threshold > 0;
336 ctx.collect_dict_samples = config.dictionaries.enabled;
337 ctx.inline_threshold = config.defaults.inline_threshold as usize;
338 ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
339 ctx.emit_shared_inline = config.defaults.shared_inline;
340 ctx.base_drop_index = Some(base_drop_index);
341 ctx.base_root = Some(base_root);
342
343 let root_inode_number = ctx.walk(root)?;
345 ctx.root_inode_number = root_inode_number;
346 write_directory_body(&mut ctx, config)?;
347 Ok(ctx.assemble())
348}
349
350pub trait BaseDropSet: Send + Sync {
363 fn base_contains(&self, drop_id: &[u8; 32]) -> bool;
366}
367
368impl BaseDropSet for std::collections::HashSet<[u8; 32]> {
369 fn base_contains(&self, drop_id: &[u8; 32]) -> bool {
370 self.contains(drop_id)
371 }
372}
373
374#[cfg(feature = "sparse-index")]
380pub struct SparseBackedBaseIndex {
381 bloom: crate::sparse_index::SparseIndexReader,
382 manifest_path: std::path::PathBuf,
383 exact: std::sync::OnceLock<std::collections::HashSet<[u8; 32]>>,
384}
385
386#[cfg(feature = "sparse-index")]
387impl SparseBackedBaseIndex {
388 #[must_use]
391 pub fn open(base_image: &Path) -> Option<Self> {
392 let sidecar = base_image.with_extension("lim.sparse");
393 let bloom = crate::sparse_index::SparseIndexReader::from_file(&sidecar)?;
394 Some(Self {
395 bloom,
396 manifest_path: base_image.to_path_buf(),
397 exact: std::sync::OnceLock::new(),
398 })
399 }
400
401 fn load_exact(&self) -> &std::collections::HashSet<[u8; 32]> {
402 self.exact.get_or_init(|| {
403 let bytes = std::fs::read(&self.manifest_path).unwrap_or_default();
407 let mut cursor = ManifestCursor::new(&bytes);
408 let _ = parse_manifest_header(&mut cursor);
409 let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
410 let _ = limnifs_core::parse_metadata_reference(&mut cursor);
411 let Ok(index) = parse_slab_index(&mut cursor) else {
412 return std::collections::HashSet::new();
413 };
414 match SlabStore::load_mmap(&self.manifest_path, &index) {
415 Ok(store) => store.drop_index_keys().copied().collect(),
416 Err(_) => std::collections::HashSet::new(),
417 }
418 })
419 }
420}
421
422#[cfg(feature = "sparse-index")]
423impl BaseDropSet for SparseBackedBaseIndex {
424 fn base_contains(&self, drop_id: &[u8; 32]) -> bool {
425 if !self.bloom.probably_contains(drop_id) {
430 return false;
431 }
432 self.load_exact().contains(drop_id)
433 }
434}
435
436#[cfg(feature = "sparse-index")]
444pub fn emit_sparse_sidecar(artifact: &WriteArtifact, image_path: &Path) -> Result<(), WriteError> {
445 let all: std::collections::HashSet<[u8; 32]> = artifact
446 .slabs
447 .iter()
448 .flat_map(|s| s.drop_ids.iter().copied())
449 .collect();
450 let mut writer = crate::sparse_index::SparseIndexWriter::new(
451 all.len().max(1),
452 crate::sparse_index::DEFAULT_FPP,
453 );
454 writer.insert_all(&all);
455 let sidecar = image_path.with_extension("lim.sparse");
456 writer.write_to_file(&sidecar).map_err(WriteError::Io)
457}
458
459fn load_base_dictionary_section(
465 base_image: &Path,
466) -> Result<Option<limnifs_core::dictionary_section::DictionarySection>, WriteError> {
467 let manifest_bytes = std::fs::read(base_image)?;
468 let mut cursor = ManifestCursor::new(&manifest_bytes);
469 let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
470 let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
471 let _ = limnifs_core::parse_metadata_reference(&mut cursor);
472 let _ = parse_slab_index(&mut cursor);
473 let _ = limnifs_core::parse_history(&mut cursor);
474 if cursor.remaining_len() == 0 {
475 return Ok(None);
476 }
477 Ok(parse_dictionary_section(&mut cursor).ok())
478}
479
480fn load_base_drop_index(
481 base_image: &Path,
482) -> Result<(std::collections::HashSet<[u8; 32]>, [u8; 32]), WriteError> {
483 let manifest_bytes = std::fs::read(base_image)?;
484 let mut cursor = ManifestCursor::new(&manifest_bytes);
485 let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
486 let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
488 let _ = limnifs_core::parse_metadata_reference(&mut cursor);
489 let slab_index = parse_slab_index(&mut cursor).map_err(io_core)?;
490 let store = SlabStore::load_mmap(base_image, &slab_index).map_err(io_core)?;
491 let drop_set: std::collections::HashSet<[u8; 32]> = store.drop_index_keys().copied().collect();
492 let root = *compute_merkle_root_from_sections(&manifest_bytes).as_bytes();
498 Ok((drop_set, root))
499}
500
501fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
507 use limnifs_core::SectionHashes;
508 let mut cursor = ManifestCursor::new(manifest);
509 let header_start = 0;
510 if parse_manifest_header(&mut cursor).is_err() {
511 return ManifestRoot::from_bytes([0u8; 32]);
513 }
514 let header_end = cursor.position();
515 let flags_start = header_end;
517 let flags_end = match limnifs_core::parse_feature_flags_section(&mut cursor) {
518 Ok(_) => cursor.position(),
519 Err(_) => flags_start,
520 };
521 let meta_ref_start = flags_end;
522 let metadata_reference = match limnifs_core::parse_metadata_reference(&mut cursor) {
523 Ok(m) => Some(m),
524 Err(_) => None,
525 };
526 let meta_ref_end = cursor.position();
527 let slab_index_start = meta_ref_end;
528 let _ = parse_slab_index(&mut cursor);
529 let slab_index_end = cursor.position();
530 let history_start = slab_index_end;
531 let _ = limnifs_core::parse_history(&mut cursor);
532 let history_end = cursor.position();
533
534 let hashes = SectionHashes {
535 metadata: metadata_reference
536 .map(|m| m.metadata_hash)
537 .unwrap_or_else(hash_empty_section),
538 format_header: hash_section(&manifest[header_start..header_end]),
539 feature_flags: hash_section(&manifest[flags_start..flags_end]),
540 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
541 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
542 crypto_params: hash_empty_section(),
543 ec_params: hash_empty_section(),
544 dms_policy: hash_empty_section(),
545 delta_linkage: hash_empty_section(),
546 history: hash_section(&manifest[history_start..history_end]),
547 };
548 compute_merkle_root(&hashes)
549}
550
551fn io_core(e: limnifs_core::CoreError) -> WriteError {
552 WriteError::Io(std::io::Error::other(format!("base image load: {e}")))
553}
554
555fn write_directory_body(ctx: &mut WriteContext, config: &WriteConfig) -> Result<(), WriteError> {
560 use rayon::prelude::*;
561
562 ctx.metadata_codec = config
563 .metadata_codec_id()
564 .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
565
566 ctx.chunker = chunker_from_config(config)?;
567
568 let pending = std::mem::take(&mut ctx.pending_files);
569 if pending.is_empty() {
570 return Ok(());
571 }
572 ctx.inline_threshold = config.defaults.inline_threshold as usize;
573 ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
574 ctx.emit_shared_inline = config.defaults.shared_inline;
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: Option<&dyn BaseDropSet> = ctx.base_drop_index.as_deref();
598 let inline_threshold = ctx.inline_threshold;
599 let max_drop_size = config.defaults.max_drop_size as usize;
600 let seekable_drops = config.defaults.seekable_drops;
601 let seekable_drops = config.defaults.seekable_drops;
602 let results: Vec<ChunkedFileResult> = pending
603 .par_iter()
604 .map(|pf| {
605 process_file(
606 pf,
607 &chunker,
608 classifier,
609 text_codec,
610 binary_codec,
611 &tunables,
612 use_categorizers,
613 skip_chunking,
614 &tournament_spec,
615 base_drop_index,
616 inline_threshold,
617 max_drop_size,
618 seekable_drops,
619 config.categorizers.as_slice(),
620 &|name| {
621 config
622 .codec_registry()
623 .ok()
624 .and_then(|r| r.lookup_by_name(name))
625 },
626 )
627 })
628 .collect::<Result<Vec<_>, _>>()?;
629
630 for (pf, result) in pending.iter().zip(results) {
631 ctx.merge_chunked_file(pf, result);
632 }
633 ctx.train_and_apply_dictionary(&config.dictionaries);
634 Ok(())
635}
636
637pub fn write_directory_with_config(
639 root: &Path,
640 config: &WriteConfig,
641) -> Result<WriteArtifact, WriteError> {
642 let mut ctx = WriteContext::new();
643 ctx.chunker = chunker_from_config(config)?;
644 ctx.categorizers_disabled = config.categorizers.is_empty();
645 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
646 ctx.auto_turnover = config.turnover_threshold > 0;
647 ctx.collect_dict_samples = config.dictionaries.enabled;
648
649 write_directory_streaming(&mut ctx, root, config)?;
650 Ok(ctx.assemble())
651}
652
653fn write_directory_streaming(
667 ctx: &mut WriteContext,
668 root: &Path,
669 config: &WriteConfig,
670) -> Result<(), WriteError> {
671 use rayon::prelude::*;
672
673 ctx.metadata_codec = config
674 .metadata_codec_id()
675 .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
676
677 ctx.chunker = chunker_from_config(config)?;
678
679 let chunker = ctx.chunker.clone();
680 let classifier = ctx.classifier;
681 let text_codec = config.text_codec_id().unwrap_or(0x04);
682 let binary_codec = config.binary_codec_id().unwrap_or(0x01);
683 let tunables = config.to_core_tunables();
684 let use_categorizers = !config.categorizers.is_empty();
685 let skip_chunking = config.skip_chunking;
686 let registry = config
687 .codec_registry()
688 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
689 let tournament_codec_ids: Vec<u8> = config
690 .tournament
691 .codecs
692 .iter()
693 .filter_map(|n| registry.lookup_by_name(n))
694 .collect();
695 let tournament_spec = TournamentSpec {
696 codec_ids: tournament_codec_ids,
697 min_size: config.tournament.min_size_threshold as usize,
698 skip_for_binary: config.tournament.skip_for_binary,
699 short_circuit_permille: config.tournament.short_circuit_threshold,
700 };
701 let base_drop_index = ctx.base_drop_index.clone();
704 let inline_threshold = ctx.inline_threshold;
705 let max_drop_size = config.defaults.max_drop_size as usize;
706 let seekable_drops = config.defaults.seekable_drops;
707
708 ctx.inline_threshold = config.defaults.inline_threshold as usize;
709 ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
710 ctx.emit_shared_inline = config.defaults.shared_inline;
711
712 const PIPELINE_CAPACITY: usize = 256;
716 let (tx, rx) = std::sync::mpsc::sync_channel::<PendingFile>(PIPELINE_CAPACITY);
717 ctx.pending_sink = Some(tx);
718
719 let (root_inode_number, mut results): (
720 u64,
721 Vec<(usize, PendingFile, Result<ChunkedFileResult, WriteError>)>,
722 ) = {
723 let survey = survey_tree(root)?;
733 std::thread::scope(|scope| {
734 let producer = {
735 let ctx = &mut *ctx;
736 let root = root;
737 scope.spawn(move || {
738 let r = ctx.fold_survey(root, &survey, None);
739 ctx.pending_sink = None;
743 r
744 })
745 };
746 let results = rx
749 .into_iter()
750 .enumerate()
751 .par_bridge()
752 .map(|(i, pf)| {
753 let r = process_file(
754 &pf,
755 &chunker,
756 classifier,
757 text_codec,
758 binary_codec,
759 &tunables,
760 use_categorizers,
761 skip_chunking,
762 &tournament_spec,
763 base_drop_index.as_deref(),
764 inline_threshold,
765 max_drop_size,
766 seekable_drops,
767 config.categorizers.as_slice(),
768 &|name| {
769 config
770 .codec_registry()
771 .ok()
772 .and_then(|r| r.lookup_by_name(name))
773 },
774 );
775 (i, pf, r)
776 })
777 .collect();
778 let joined = producer
779 .join()
780 .unwrap_or_else(|_| {
781 Err(WriteError::Io(std::io::Error::other(
782 "walk thread panicked",
783 )))
784 })
785 .map(|n| (n, results));
786 joined
789 })
790 }?;
791 ctx.pending_sink = None;
792 ctx.root_inode_number = root_inode_number;
793
794 results.sort_unstable_by_key(|(i, _, _)| *i);
795 for (_, pf, r) in results {
799 ctx.merge_chunked_file(&pf, r?);
800 }
801 ctx.train_and_apply_dictionary(&config.dictionaries);
802 Ok(())
803}
804
805pub(crate) type RawDrop = ([u8; 32], Vec<u8>, std::sync::Arc<[u8]>, u8, u8);
812pub(crate) struct ChunkedFileResult {
814 drops: Vec<RawDrop>, slices: Vec<PendingSlice>,
816}
817
818struct TournamentSpec {
826 codec_ids: Vec<u8>,
830 min_size: usize,
834 skip_for_binary: bool,
838 short_circuit_permille: u32,
843}
844
845fn chunker_from_config(config: &WriteConfig) -> Result<ParallelFastCDC, WriteError> {
864 ParallelFastCDC::new(
865 config.chunking.min_chunk_size as usize,
866 config.chunking.avg_chunk_size as usize,
867 config.chunking.max_chunk_size as usize,
868 )
869 .map_err(|e| WriteError::Io(std::io::Error::other(format!("chunking config: {e}"))))
870}
871
872pub(crate) fn seekable_or_monolithic(
879 codec: u8,
880 plaintext: &[u8],
881 compressed: std::sync::Arc<[u8]>,
882 tunables: &limnifs_core::codec::CodecTunables,
883 seekable_drops: bool,
884 threshold: usize,
885) -> (std::sync::Arc<[u8]>, u8) {
886 use limnifs_core::seekable::{
887 encode_seekable, is_seekable_codec, DROP_FLAG_SEEKABLE as FLAG, SEEKABLE_EMISSION_THRESHOLD,
888 };
889 if seekable_drops && plaintext.len() > threshold && is_seekable_codec(codec) {
890 if let Ok(container) = encode_seekable(codec, plaintext, tunables) {
891 return (container.into(), FLAG);
892 }
893 }
894 (compressed, 0)
895}
896
897pub(crate) const SEEKABLE_CHUNK_EMISSION_THRESHOLD: usize =
904 limnifs_core::seekable::SEEKABLE_FRAME_SIZE;
905
906fn process_whole_file_drop(
907 pf: &PendingFile,
908 data: &[u8],
909 cat: file_categorizer::Categorization,
910 tunables: &limnifs_core::codec::CodecTunables,
911 seekable_drops: bool,
912) -> Result<ChunkedFileResult, WriteError> {
913 let _ = pf;
914 let drop_id = hash_section(data);
915 let file_len = u64::try_from(data.len()).unwrap_or(u64::MAX);
916
917 let (mut best_codec, mut best_compressed): (u8, std::sync::Arc<[u8]>) =
922 match limnifs_core::codec::compress_with_tunables(
923 limnifs_core::codec::CODEC_BROTLI,
924 data,
925 tunables,
926 ) {
927 Ok(c) => (limnifs_core::codec::CODEC_BROTLI, c.into()),
928 Err(_) => match limnifs_core::codec::compress_with_tunables(
929 limnifs_core::codec::CODEC_ZSTD,
930 data,
931 tunables,
932 ) {
933 Ok(c) => (limnifs_core::codec::CODEC_ZSTD, c.into()),
934 Err(_) => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
935 },
936 };
937
938 let brotli_ratio = best_compressed.len() as f64 / data.len() as f64;
942 if brotli_ratio > 0.05 {
943 if let Ok(zstd_c) = limnifs_core::codec::compress_with_tunables(
944 limnifs_core::codec::CODEC_ZSTD,
945 data,
946 tunables,
947 ) {
948 if zstd_c.len() < best_compressed.len() {
949 best_codec = limnifs_core::codec::CODEC_ZSTD;
950 best_compressed = zstd_c.into();
951 }
952 }
953 }
954
955 let general_ratio = best_compressed.len() as f64 / data.len() as f64;
961 if general_ratio > 0.15 || cat.codec_id == limnifs_core::codec::CODEC_RICEPP {
962 let spec_result = if cat.codec_id == limnifs_core::codec::CODEC_FSST_BROTLI {
966 limnifs_core::codec::fsst_brotli::compress_with_baseline(data, Some(&best_compressed))
967 } else {
968 limnifs_core::codec::compress_with_tunables(cat.codec_id, data, tunables)
972 };
973 if let Ok(spec_c) = spec_result {
974 if spec_c.len() < best_compressed.len() {
975 best_codec = cat.codec_id;
976 best_compressed = spec_c.into();
977 }
978 }
979 }
980
981 let (best_compressed, flags) = seekable_or_monolithic(
982 best_codec,
983 data,
984 best_compressed,
985 tunables,
986 seekable_drops,
987 limnifs_core::seekable::SEEKABLE_EMISSION_THRESHOLD,
988 );
989 Ok(ChunkedFileResult {
990 drops: vec![(drop_id, data.to_vec(), best_compressed, best_codec, flags)],
991 slices: vec![PendingSlice {
992 drop_id,
993 file_byte_start: 0,
994 file_byte_end: file_len,
995 }],
996 })
997}
998
999fn compress_chunk_with_tournament(
1021 chunk: &[u8],
1022 class: classifier::Class,
1023 text_codec: u8,
1024 binary_codec: u8,
1025 tunables: &limnifs_core::codec::CodecTunables,
1026 tournament: &TournamentSpec,
1027) -> (u8, std::sync::Arc<[u8]>) {
1028 use classifier::Class;
1029
1030 let preferred = match class {
1031 Class::Binary => binary_codec,
1032 Class::Text | Class::Code | Class::Sparse => text_codec,
1033 _ => limnifs_core::codec::CODEC_STORE,
1034 };
1035
1036 if preferred == limnifs_core::codec::CODEC_STORE {
1037 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
1038 }
1039 if class == Class::Binary && tournament.skip_for_binary {
1040 return compress_chunk_one(chunk, preferred, tunables);
1041 }
1042 if chunk.len() < tournament.min_size {
1043 return compress_chunk_one(chunk, preferred, tunables);
1044 }
1045
1046 let mut best: Option<(u8, std::sync::Arc<[u8]>)> = None;
1047 for &codec_id in &tournament.codec_ids {
1048 if codec_id == limnifs_core::codec::CODEC_STORE {
1049 continue;
1050 }
1051 let c = match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
1052 Ok(c) => c,
1053 Err(_) => continue,
1054 };
1055 if c.len() >= chunk.len() {
1056 continue;
1057 }
1058 let ratio_permille = (c.len() as u64 * 1000 / chunk.len() as u64) as u32;
1059 let is_best_so_far = best.as_ref().map_or(true, |(_, b)| c.len() < b.len());
1060 if is_best_so_far {
1061 best = Some((codec_id, c.into()));
1062 }
1063 if tournament.short_circuit_permille > 0
1064 && ratio_permille <= tournament.short_circuit_permille
1065 {
1066 break;
1067 }
1068 }
1069
1070 best.unwrap_or_else(|| (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()))
1071}
1072
1073fn compress_chunk_one(
1076 chunk: &[u8],
1077 codec_id: u8,
1078 tunables: &limnifs_core::codec::CodecTunables,
1079) -> (u8, std::sync::Arc<[u8]>) {
1080 if codec_id == limnifs_core::codec::CODEC_STORE {
1081 return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
1082 }
1083 match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
1084 Ok(c) if c.len() < chunk.len() => (codec_id, c.into()),
1085 _ => (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()),
1086 }
1087}
1088
1089fn process_file(
1093 pf: &PendingFile,
1094 chunker: &dyn Chunker,
1095 classifier: classifier::Classifier,
1096 text_codec: u8,
1097 binary_codec: u8,
1098 tunables: &limnifs_core::codec::CodecTunables,
1099 use_categorizers: bool,
1100 skip_chunking: bool,
1101 tournament: &TournamentSpec,
1102 base_drop_index: Option<&dyn BaseDropSet>,
1103 inline_threshold: usize,
1104 max_drop_size: usize,
1105 seekable_drops: bool,
1106 categorizer_config: &[crate::config::CategorizerConfig],
1107 codec_name_resolver: &dyn Fn(&str) -> Option<u8>,
1108) -> Result<ChunkedFileResult, WriteError> {
1109 let file_len_estimate = std::fs::metadata(&pf.path)
1120 .map(|m| m.len() as usize)
1121 .unwrap_or(0);
1122 let mmap_handle: memmap2::Mmap;
1129 let small: Vec<u8>;
1130 let data: &[u8] = if file_len_estimate >= MMAP_READ_THRESHOLD {
1131 let file = std::fs::File::open(&pf.path)?;
1132 #[allow(unsafe_code)]
1136 let mapped = unsafe { memmap2::Mmap::map(&file) }.map_err(WriteError::Io)?;
1137 mmap_handle = mapped;
1138 &mmap_handle[..]
1139 } else {
1140 small = std::fs::read(&pf.path)?;
1141 &small[..]
1142 };
1143 let file_len = data.len();
1144
1145 if skip_chunking && file_len > inline_threshold {
1152 let drop_id = hash_section(&data);
1153 let class = classifier.classify(&data);
1154 let preferred_codec = match class {
1155 classifier::Class::Binary => binary_codec,
1156 _ => text_codec,
1157 };
1158 let (codec_id, compressed): (u8, std::sync::Arc<[u8]>) =
1159 match limnifs_core::codec::compress_with_tunables(preferred_codec, &data, tunables) {
1160 Ok(c) if c.len() < data.len() => (preferred_codec, c.into()),
1161 _ => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
1162 };
1163 let (compressed, flags) = seekable_or_monolithic(
1164 codec_id,
1165 &data,
1166 compressed,
1167 tunables,
1168 seekable_drops,
1169 SEEKABLE_CHUNK_EMISSION_THRESHOLD,
1170 );
1171 return Ok(ChunkedFileResult {
1172 drops: vec![(drop_id, data.to_vec(), compressed, codec_id, flags)],
1173 slices: vec![PendingSlice {
1174 drop_id,
1175 file_byte_start: 0,
1176 file_byte_end: file_len as u64,
1177 }],
1178 });
1179 }
1180
1181 if use_categorizers {
1182 let config_cat = file_categorizer::ConfigCategorizer::new(categorizer_config.to_vec());
1185 if let Some(cat) = config_cat.categorize(&pf.path, &data) {
1186 if let Some(codec_id) = file_categorizer::resolve_config_categorization(
1187 &cat,
1188 categorizer_config,
1189 codec_name_resolver,
1190 ) {
1191 let within_cap = max_drop_size == 0 || file_len <= max_drop_size;
1192 let needs_whole_file = matches!(
1193 codec_id,
1194 limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
1195 );
1196 if within_cap && (needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE) {
1197 let mut cat = cat;
1198 cat.codec_id = codec_id;
1199 return process_whole_file_drop(pf, &data, cat, tunables, seekable_drops);
1200 }
1201 }
1202 }
1203 if let Some(cat) = file_categorizer::default_registry().categorize(&pf.path, &data) {
1204 let needs_whole_file = matches!(
1205 cat.codec_id,
1206 limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
1207 );
1208 let within_cap = max_drop_size == 0 || file_len <= max_drop_size;
1211 if within_cap && (needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE) {
1212 return process_whole_file_drop(pf, &data, cat, tunables, seekable_drops);
1213 }
1214 }
1215 }
1216
1217 let chunks = chunker.chunk_slice(&data);
1218
1219 use rayon::prelude::*;
1228 let drop_ids: Vec<[u8; 32]> = chunks.par_iter().map(|chunk| hash_section(chunk)).collect();
1229
1230 let mut slices = Vec::with_capacity(chunks.len());
1231 let mut file_offset: u64 = 0;
1232 let mut seen_in_file: std::collections::HashSet<[u8; 32]> =
1233 std::collections::HashSet::with_capacity(chunks.len());
1234 let mut unique_chunks: Vec<(&[u8], [u8; 32])> = Vec::with_capacity(chunks.len());
1235 for (chunk, drop_id) in chunks.iter().zip(drop_ids) {
1236 let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
1237 slices.push(PendingSlice {
1238 drop_id,
1239 file_byte_start: file_offset,
1240 file_byte_end: file_offset + chunk_len,
1241 });
1242 file_offset += chunk_len;
1243 if seen_in_file.insert(drop_id) {
1244 unique_chunks.push((chunk, drop_id));
1245 }
1246 }
1247
1248 thread_local! {
1260 static COMPRESS_CACHE: std::cell::RefCell<std::collections::HashMap<[u8; 32], (u8, std::sync::Arc<[u8]>)>> =
1261 std::cell::RefCell::new(std::collections::HashMap::new());
1262 }
1263 const COMPRESS_CACHE_MAX_ENTRIES: usize = 100_000;
1264 let drops: Vec<RawDrop> = unique_chunks
1265 .par_iter()
1266 .map(|(chunk, drop_id)| {
1267 if let Some(base) = base_drop_index {
1270 if base.base_contains(drop_id) {
1271 return (*drop_id, Vec::new(), Vec::new().into(), CODEC_REFERENCED, 0);
1272 }
1273 }
1274 let class = classifier.classify(chunk);
1275 let cached = COMPRESS_CACHE.with(|c| {
1278 c.borrow()
1279 .get(drop_id)
1280 .map(|(cid, comp)| (*cid, comp.clone()))
1281 });
1282 let (codec_id, compressed) = if let Some(c) = cached {
1283 c
1284 } else {
1285 let new = compress_chunk_with_tournament(
1286 chunk,
1287 class,
1288 text_codec,
1289 binary_codec,
1290 tunables,
1291 tournament,
1292 );
1293 COMPRESS_CACHE.with(|c| {
1295 let mut cache = c.borrow_mut();
1296 if cache.len() < COMPRESS_CACHE_MAX_ENTRIES {
1297 cache.insert(*drop_id, new.clone());
1299 }
1300 });
1301 new
1302 };
1303 let (compressed, flags) = seekable_or_monolithic(
1309 codec_id,
1310 chunk,
1311 compressed,
1312 tunables,
1313 seekable_drops,
1314 SEEKABLE_CHUNK_EMISSION_THRESHOLD,
1315 );
1316 (*drop_id, chunk.to_vec(), compressed, codec_id, flags)
1317 })
1318 .collect();
1319
1320 let _ = file_len;
1321 Ok(ChunkedFileResult { drops, slices })
1322}
1323
1324struct PendingDrop {
1325 id: [u8; 32],
1326 plaintext_len: u32,
1332 compressed: std::sync::Arc<[u8]>,
1333 codec: u8,
1334 dict_id: u8,
1338 plaintext: Option<Vec<u8>>,
1343 flags: u8,
1347}
1348
1349impl PendingDrop {
1350 fn len_in_window(&self) -> u32 {
1354 u32::try_from(self.compressed.len()).expect("compressed fits u32")
1355 }
1356
1357 fn plaintext_len_value(&self) -> u32 {
1359 self.plaintext_len
1360 }
1361
1362 fn slab_footprint(&self) -> usize {
1365 48 + self.compressed.len()
1366 }
1367}
1368
1369struct PendingSlice {
1374 drop_id: [u8; 32],
1375 file_byte_start: u64,
1376 file_byte_end: u64,
1377}
1378
1379#[derive(Clone)]
1382struct PendingFile {
1383 inode_number: u64,
1384 path: PathBuf,
1385 mtime_ns: u64,
1386 file_len: u64,
1387 mode: u32,
1388 uid: u32,
1389 gid: u32,
1390}
1391
1392#[derive(Clone, Copy, Default)]
1396struct SurveyMeta {
1397 is_dir: bool,
1398 is_file: bool,
1399 is_symlink: bool,
1400 #[cfg(unix)]
1401 is_fifo: bool,
1402 #[cfg(unix)]
1403 is_socket: bool,
1404 #[cfg(unix)]
1405 is_block_device: bool,
1406 #[cfg(unix)]
1407 is_char_device: bool,
1408 len: u64,
1409 mtime_ns: u64,
1410 #[cfg(unix)]
1411 mode: u32,
1412 #[cfg(unix)]
1413 uid: u32,
1414 #[cfg(unix)]
1415 gid: u32,
1416}
1417
1418impl SurveyMeta {
1419 fn identity(&self) -> (u32, u32, u32) {
1423 #[cfg(unix)]
1424 {
1425 (self.mode, self.uid, self.gid)
1426 }
1427 #[cfg(not(unix))]
1428 {
1429 let ty = if self.is_dir {
1430 limnifs_core::inode::S_IFDIR
1431 } else if self.is_symlink {
1432 limnifs_core::inode::S_IFLNK
1433 } else {
1434 limnifs_core::inode::S_IFREG
1435 };
1436 let perms = if self.is_dir || self.is_symlink {
1437 0o755
1438 } else {
1439 0o644
1440 };
1441 (ty | perms, 0, 0)
1442 }
1443 }
1444}
1445
1446struct SurveyNode {
1449 meta: SurveyMeta,
1450 children: Vec<(String, SurveyNode)>,
1451 symlink_target: Option<String>,
1455}
1456
1457impl SurveyNode {
1458 fn meta(&self) -> SurveyMeta {
1459 self.meta
1460 }
1461}
1462
1463fn survey_meta_of(meta: &std::fs::Metadata) -> SurveyMeta {
1464 #[cfg(unix)]
1465 use std::os::unix::fs::FileTypeExt as _;
1466 let ft = meta.file_type();
1467 let mtime_ns = meta
1468 .modified()
1469 .ok()
1470 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1471 .map_or(0u128, |d| d.as_nanos());
1472 SurveyMeta {
1473 is_dir: ft.is_dir(),
1474 is_file: ft.is_file(),
1475 is_symlink: ft.is_symlink(),
1476 #[cfg(unix)]
1477 is_fifo: ft.is_fifo(),
1478 #[cfg(unix)]
1479 is_socket: ft.is_socket(),
1480 #[cfg(unix)]
1481 is_block_device: ft.is_block_device(),
1482 #[cfg(unix)]
1483 is_char_device: ft.is_char_device(),
1484 len: meta.len(),
1485 mtime_ns: mtime_ns.try_into().unwrap_or(0),
1486 #[cfg(unix)]
1487 mode: {
1488 use std::os::unix::fs::MetadataExt as _;
1489 meta.mode()
1490 },
1491 #[cfg(unix)]
1492 uid: {
1493 use std::os::unix::fs::MetadataExt as _;
1494 meta.uid()
1495 },
1496 #[cfg(unix)]
1497 gid: {
1498 use std::os::unix::fs::MetadataExt as _;
1499 meta.gid()
1500 },
1501 }
1502}
1503
1504fn survey_node(path: &Path) -> Result<SurveyNode, WriteError> {
1505 use rayon::prelude::*;
1506 let meta = std::fs::symlink_metadata(path)?;
1507 let sm = survey_meta_of(&meta);
1508 if sm.is_symlink {
1509 let target = std::fs::read_link(path)?;
1510 let target = target
1511 .to_str()
1512 .ok_or_else(|| WriteError::UnsupportedFileType {
1513 path: path.to_path_buf(),
1514 kind: format!("symlink with non-UTF-8 target ({})", target.display()),
1515 })?
1516 .to_owned();
1517 return Ok(SurveyNode {
1518 meta: sm,
1519 children: Vec::new(),
1520 symlink_target: Some(target),
1521 });
1522 }
1523 if !sm.is_dir {
1524 return Ok(SurveyNode {
1525 meta: sm,
1526 children: Vec::new(),
1527 symlink_target: None,
1528 });
1529 }
1530 let mut named: Vec<(String, PathBuf)> = std::fs::read_dir(path)?
1531 .filter_map(|entry| {
1532 entry
1533 .ok()
1534 .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
1535 })
1536 .collect();
1537 named.sort_by(|a, b| a.0.cmp(&b.0));
1538 named
1539 .par_iter()
1540 .map(|(name, child)| survey_node(child).map(|node| (name.clone(), node)))
1541 .collect::<Result<Vec<_>, WriteError>>()
1542 .map(|children| SurveyNode {
1543 meta: sm,
1544 children,
1545 symlink_target: None,
1546 })
1547}
1548
1549fn survey_tree(root: &Path) -> Result<SurveyNode, WriteError> {
1554 survey_node(root)
1555}
1556
1557struct PendingInode {
1558 number: u64,
1559 mode: u32,
1560 uid: u32,
1561 gid: u32,
1562 mtime_ns: u64,
1563 content: PendingContent,
1564}
1565
1566enum PendingContent {
1567 Inline(Vec<u8>),
1568 Symlink(String),
1570 DropBacked {
1571 file_len: u64,
1572 slices: Vec<PendingSlice>,
1573 },
1574 Directory(Vec<(String, u64, u8)>),
1575}
1576
1577struct DirNode {
1578 entries: Vec<(String, u64, u8)>,
1579 bytes: Vec<u8>,
1580 hash: [u8; 32],
1581}
1582
1583struct WriteContext {
1584 next_inode: u64,
1585 inodes: Vec<PendingInode>,
1586 dir_nodes: Vec<DirNode>,
1587 drops: Vec<PendingDrop>,
1588 drop_index: HashSet<[u8; 32]>,
1589 pending_files: Vec<PendingFile>,
1590 file_count: usize,
1591 dir_count: usize,
1592 root_inode_number: u64,
1593 chunker: ParallelFastCDC,
1594 classifier: classifier::Classifier,
1595 shared_inline_map: HashMap<[u8; 32], usize>,
1596 shared_inline_table: Vec<Vec<u8>>,
1597 base_dictionaries: Option<Vec<crate::dictionary::TrainedDictionary>>,
1603 profile_name: Option<String>,
1605 metadata_codec: u8,
1608 categorizers_disabled: bool,
1610 rw_mode: bool,
1612 auto_turnover: bool,
1614 collect_dict_samples: bool,
1617 dict_samples_by_class: HashMap<crate::classifier::Class, Vec<Vec<u8>>>,
1624 trained_dicts_by_class: HashMap<crate::classifier::Class, crate::dictionary::TrainedDictionary>,
1629 base_drop_index: Option<std::sync::Arc<dyn BaseDropSet>>,
1635 base_root: Option<[u8; 32]>,
1640 metadata_externalize_threshold: usize,
1645 emit_shared_inline: bool,
1651 inline_threshold: usize,
1656 pending_sink: Option<std::sync::mpsc::SyncSender<PendingFile>>,
1662}
1663
1664impl WriteContext {
1665 const MAX_DICT_SAMPLES: usize = 1000;
1668
1669 fn new() -> Self {
1670 Self {
1671 next_inode: 1,
1672 inodes: Vec::new(),
1673 dir_nodes: Vec::new(),
1674 drops: Vec::new(),
1675 drop_index: HashSet::new(),
1676 pending_files: Vec::new(),
1677 file_count: 0,
1678 dir_count: 0,
1679 root_inode_number: 0,
1680 chunker: ParallelFastCDC::default(),
1681 classifier: classifier::Classifier,
1682 shared_inline_map: HashMap::new(),
1683 shared_inline_table: Vec::new(),
1684 base_dictionaries: None,
1685 profile_name: None,
1686 metadata_codec: limnifs_core::codec::CODEC_BROTLI,
1687 categorizers_disabled: false,
1688 rw_mode: false,
1689 auto_turnover: false,
1690 collect_dict_samples: false,
1691 dict_samples_by_class: HashMap::new(),
1692 trained_dicts_by_class: HashMap::new(),
1693 base_drop_index: None,
1694 base_root: None,
1695 pending_sink: None,
1696 inline_threshold: INLINE_THRESHOLD,
1697 metadata_externalize_threshold: METADATA_EXTERNALIZE_THRESHOLD,
1698 emit_shared_inline: true,
1699 }
1700 }
1701
1702 fn alloc_inode(&mut self) -> u64 {
1703 let n = self.next_inode;
1704 self.next_inode += 1;
1705 n
1706 }
1707
1708 fn build_shared_inline_table(&mut self) {
1712 let mut counts: HashMap<[u8; 32], usize> = HashMap::new();
1713 for inode in &self.inodes {
1714 if let PendingContent::Inline(data) = &inode.content {
1715 let h = hash_section(data);
1716 *counts.entry(h).or_default() += 1;
1717 }
1718 }
1719 for inode in &self.inodes {
1721 if let PendingContent::Inline(data) = &inode.content {
1722 let h = hash_section(data);
1723 if counts.get(&h).copied().unwrap_or(0) > 1
1724 && !self.shared_inline_map.contains_key(&h)
1725 {
1726 let idx = self.shared_inline_table.len();
1727 self.shared_inline_table.push(data.clone());
1728 self.shared_inline_map.insert(h, idx);
1729 }
1730 }
1731 }
1732 }
1733
1734 fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
1737 for (drop_id, plaintext, compressed, codec, flags) in result.drops {
1738 if self.drop_index.insert(drop_id) {
1739 let retain_plaintext =
1745 self.collect_dict_samples && codec == limnifs_core::codec::CODEC_ZSTD;
1746 if retain_plaintext {
1747 let total: usize = self.dict_samples_by_class.values().map(Vec::len).sum();
1748 if total < Self::MAX_DICT_SAMPLES {
1749 let class = self.classifier.classify(&plaintext);
1750 self.dict_samples_by_class
1751 .entry(class)
1752 .or_default()
1753 .push(plaintext.clone());
1754 }
1755 }
1756 self.drops.push(PendingDrop {
1757 id: drop_id,
1758 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1759 compressed,
1760 codec,
1761 dict_id: limnifs_core::drop_record::NO_DICT,
1762 plaintext: if retain_plaintext {
1763 Some(plaintext)
1764 } else {
1765 None
1766 },
1767 flags,
1768 });
1769 }
1770 }
1771 self.inodes.push(PendingInode {
1772 number: pf.inode_number,
1773 mode: pf.mode,
1774 uid: pf.uid,
1775 gid: pf.gid,
1776 mtime_ns: pf.mtime_ns,
1777 content: PendingContent::DropBacked {
1778 file_len: pf.file_len,
1779 slices: result.slices,
1780 },
1781 });
1782 }
1783
1784 #[allow(dead_code)]
1795 fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
1796 let class = self.classifier.classify(plaintext);
1797 let (codec, compressed): (u8, std::sync::Arc<[u8]>) = match class {
1798 classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
1799 let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
1800 (limnifs_core::codec::CODEC_LZ4, c.into())
1801 }
1802 _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec().into()),
1803 };
1804 PendingDrop {
1805 id: drop_id,
1806 plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1807 compressed,
1808 codec,
1809 dict_id: limnifs_core::drop_record::NO_DICT,
1810 plaintext: None,
1811 flags: 0,
1812 }
1813 }
1814
1815 fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
1816 let survey = survey_tree(path)?;
1824 self.fold_survey(path, &survey, None)
1825 }
1826
1827 fn fold_survey(
1831 &mut self,
1832 path: &Path,
1833 node: &SurveyNode,
1834 symlink_target: Option<&str>,
1835 ) -> Result<u64, WriteError> {
1836 let meta = node.meta();
1837 if let Some(target) = symlink_target {
1838 let inode_number = self.alloc_inode();
1839 let (mode, uid, gid) = meta.identity();
1840 self.inodes.push(PendingInode {
1841 number: inode_number,
1842 mode,
1843 uid,
1844 gid,
1845 mtime_ns: meta.mtime_ns,
1846 content: PendingContent::Symlink(target.to_owned()),
1847 });
1848 return Ok(inode_number);
1849 }
1850 if meta.is_dir {
1851 self.dir_count += 1;
1852 let inode_number = self.alloc_inode();
1853 let mut entries: Vec<(String, u64, u8)> = Vec::new();
1854
1855 for (name, child) in &node.children {
1856 let child_path = path.join(name);
1857 let child_inode =
1858 self.fold_survey(&child_path, child, child.symlink_target.as_deref())?;
1859 let entry_type = if child.meta().is_symlink {
1860 0x03
1861 } else if child.meta().is_dir {
1862 0x02
1863 } else {
1864 0x01
1865 };
1866 entries.push((name.clone(), child_inode, entry_type));
1867 }
1868
1869 entries.sort_by(|a, b| a.0.cmp(&b.0));
1872 let dir_node = encode_dir_node(&entries);
1873 self.dir_nodes.push(dir_node);
1874 let (mode, uid, gid) = meta.identity();
1875 self.inodes.push(PendingInode {
1876 number: inode_number,
1877 mode,
1878 uid,
1879 gid,
1880 mtime_ns: meta.mtime_ns,
1881 content: PendingContent::Directory(entries),
1882 });
1883 Ok(inode_number)
1884 } else if meta.is_file {
1885 self.file_count += 1;
1886 let inode_number = self.alloc_inode();
1887 let file_len = meta.len;
1888 crate::progress::emit_file(path, file_len);
1889
1890 if file_len <= u64::try_from(self.inline_threshold).unwrap_or(u64::MAX) {
1891 let data = std::fs::read(path)?;
1892 let (mode, uid, gid) = meta.identity();
1893 self.inodes.push(PendingInode {
1894 number: inode_number,
1895 mode,
1896 uid,
1897 gid,
1898 mtime_ns: meta.mtime_ns,
1899 content: PendingContent::Inline(data),
1900 });
1901 } else {
1902 let (mode, uid, gid) = meta.identity();
1904 let pf = PendingFile {
1905 inode_number,
1906 path: path.to_path_buf(),
1907 mtime_ns: meta.mtime_ns,
1908 file_len,
1909 mode,
1910 uid,
1911 gid,
1912 };
1913 if let Some(sink) = &self.pending_sink {
1914 sink.send(pf).map_err(|_| {
1920 WriteError::Io(std::io::Error::other("walk: compress pipeline shut down"))
1921 })?;
1922 } else {
1923 self.pending_files.push(pf);
1924 }
1925 }
1926 Ok(inode_number)
1927 } else {
1928 #[cfg(unix)]
1929 let kind = {
1930 use std::os::unix::fs::FileTypeExt;
1931 if meta.is_fifo {
1932 "fifo".to_owned()
1933 } else if meta.is_socket {
1934 "socket".to_owned()
1935 } else if meta.is_block_device {
1936 "block device".to_owned()
1937 } else if meta.is_char_device {
1938 "character device".to_owned()
1939 } else {
1940 "unknown".to_owned()
1941 }
1942 };
1943 #[cfg(not(unix))]
1944 let kind = "unknown".to_owned();
1945 Err(WriteError::UnsupportedFileType {
1946 path: path.to_path_buf(),
1947 kind,
1948 })
1949 }
1950 }
1951
1952 fn train_and_apply_dictionary(&mut self, dictionaries: &crate::config::DictionaryConfig) {
1967 if !dictionaries.enabled {
1968 Self::release_dictionary_samples(self);
1969 return;
1970 }
1971
1972 if let Some(adopted) = self.base_dictionaries.take() {
1976 for dict in adopted {
1977 match dict.id {
1978 0 => {
1979 self.trained_dicts_by_class
1980 .insert(crate::classifier::Class::Text, dict);
1981 }
1982 1 => {
1983 self.trained_dicts_by_class
1984 .insert(crate::classifier::Class::Binary, dict);
1985 }
1986 _ => {}
1987 }
1988 }
1989 self.apply_trained_dictionaries();
1990 return;
1991 }
1992
1993 let target = usize::try_from(dictionaries.max_dict_size).unwrap_or(65_536);
1994 let min_class = usize::try_from(dictionaries.min_class_size).unwrap_or(0);
1995
1996 let text_classes = [
2000 crate::classifier::Class::Text,
2001 crate::classifier::Class::Code,
2002 crate::classifier::Class::Sparse,
2003 ];
2004 let binary_classes = [crate::classifier::Class::Binary];
2005
2006 let text_samples: Vec<&[u8]> = text_classes
2008 .iter()
2009 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
2010 .map(Vec::as_slice)
2011 .collect();
2012 let trainer = crate::dictionary::TrainerKind::from_config_str(&dictionaries.trainer);
2013 if text_samples.len() >= min_class {
2014 if let Some(dict) =
2015 crate::dictionary::train_zstd_with_trainer(0, &text_samples, target, trainer)
2016 {
2017 self.trained_dicts_by_class
2018 .insert(crate::classifier::Class::Text, dict);
2019 }
2020 }
2021 let binary_samples: Vec<&[u8]> = binary_classes
2022 .iter()
2023 .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
2024 .map(Vec::as_slice)
2025 .collect();
2026 if binary_samples.len() >= min_class {
2027 if let Some(dict) =
2028 crate::dictionary::train_zstd_with_trainer(1, &binary_samples, target, trainer)
2029 {
2030 self.trained_dicts_by_class
2031 .insert(crate::classifier::Class::Binary, dict);
2032 }
2033 }
2034
2035 self.apply_trained_dictionaries();
2036 }
2037
2038 fn apply_trained_dictionaries(&mut self) {
2044 let text_classes = [
2048 crate::classifier::Class::Text,
2049 crate::classifier::Class::Code,
2050 crate::classifier::Class::Sparse,
2051 ];
2052 let binary_classes = [crate::classifier::Class::Binary];
2053
2054 use rayon::prelude::*;
2067 let classifier = self.classifier;
2068 let dicts = &self.trained_dicts_by_class;
2069 let candidates: Vec<Option<(std::sync::Arc<[u8]>, u8)>> = self
2070 .drops
2071 .par_iter()
2072 .map(|d| {
2073 if d.codec != limnifs_core::codec::CODEC_ZSTD {
2074 return None;
2075 }
2076 let Some(plaintext) = d.plaintext.as_ref() else {
2077 return None;
2078 };
2079 let class = classifier.classify(plaintext);
2080 let dict_class = if text_classes.contains(&class) {
2081 crate::classifier::Class::Text
2082 } else if binary_classes.contains(&class) {
2083 crate::classifier::Class::Binary
2084 } else {
2085 return None;
2086 };
2087 let Some(dict) = dicts.get(&dict_class) else {
2088 return None;
2089 };
2090 let Ok(dict_compressed) = dict.compress(plaintext) else {
2091 return None;
2092 };
2093 if dict_compressed.len() < d.compressed.len() {
2094 Some((dict_compressed.into(), dict.id))
2095 } else {
2096 None
2097 }
2098 })
2099 .collect();
2100
2101 let saving: isize = candidates
2102 .iter()
2103 .zip(self.drops.iter())
2104 .map(|(c, d)| {
2105 c.as_ref().map_or(0, |(bytes, _)| {
2106 d.compressed.len() as isize - bytes.len() as isize
2107 })
2108 })
2109 .sum();
2110 let dict_bytes: usize = dicts.values().map(|d| d.content.len()).sum();
2111 if saving > dict_bytes as isize {
2112 for (d, candidate) in self.drops.iter_mut().zip(candidates) {
2113 if let Some((bytes, dict_id)) = candidate {
2114 d.compressed = bytes;
2115 d.dict_id = dict_id;
2116 }
2117 }
2118 } else {
2119 self.trained_dicts_by_class.clear();
2125 }
2126
2127 Self::release_dictionary_samples(self);
2128 }
2129
2130 fn release_dictionary_samples(ctx: &mut Self) {
2133 for d in &mut ctx.drops {
2134 d.plaintext = None;
2135 }
2136 ctx.dict_samples_by_class.clear();
2137 }
2138
2139 fn trace_phase(label: &str, start: std::time::Instant) {
2141 if std::env::var_os("LIMNIFS_TRACE_ASSEMBLE").is_some() {
2142 eprintln!("[assemble] {label}: {:?}", start.elapsed());
2143 }
2144 }
2145
2146 fn assemble(mut self) -> WriteArtifact {
2147 let t_assemble = std::time::Instant::now();
2148 let inode_count = self.inodes.len();
2149 let dir_count = self.dir_count;
2150 let drop_count = self.drops.len();
2151
2152 let t = std::time::Instant::now();
2158 let slabs = pack_slabs(&self.drops);
2159 Self::trace_phase("pack_slabs", t);
2160
2161 let t = std::time::Instant::now();
2165 if self.emit_shared_inline {
2166 self.build_shared_inline_table();
2167 }
2168 Self::trace_phase("shared_inline_table", t);
2169
2170 let mut metadata_blob = Vec::new();
2171 metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
2172 for inode in &self.inodes {
2173 self.encode_inode(&mut metadata_blob, inode);
2174 }
2175 metadata_blob
2176 .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
2177 for node in &self.dir_nodes {
2178 metadata_blob.extend_from_slice(&node.bytes);
2179 }
2180 if !self.shared_inline_table.is_empty() {
2183 metadata_blob.extend_from_slice(
2184 &u32::try_from(self.shared_inline_table.len())
2185 .unwrap()
2186 .to_le_bytes(),
2187 );
2188 for entry in &self.shared_inline_table {
2189 let len = u32::try_from(entry.len()).expect("shared entry fits u32");
2190 metadata_blob.extend_from_slice(&len.to_le_bytes());
2191 metadata_blob.extend_from_slice(entry);
2192 }
2193 }
2194
2195 Self::trace_phase("metadata_encode", t);
2196 let uncompressed_len =
2203 u32::try_from(metadata_blob.len()).expect("metadata blob length fits u32");
2204 let t = std::time::Instant::now();
2205 let metadata_hash = hash_section(&metadata_blob);
2206 let metadata_codec = self.metadata_codec;
2207 let metadata_quality = if metadata_blob.len() > METADATA_LARGE_BLOB_THRESHOLD {
2208 METADATA_LARGE_BLOB_QUALITY
2209 } else {
2210 METADATA_SMALL_BLOB_QUALITY
2211 };
2212 let compressed_blob = if metadata_codec == limnifs_core::codec::CODEC_BROTLI {
2213 limnifs_core::codec::compress_brotli_with_quality(&metadata_blob, metadata_quality)
2214 .unwrap_or_else(|_| metadata_blob.clone())
2215 } else {
2216 limnifs_core::codec::compress(metadata_codec, &metadata_blob)
2217 .unwrap_or_else(|_| metadata_blob.clone())
2218 };
2219 Self::trace_phase("metadata_compress", t);
2220 let (on_wire_codec, on_wire_blob) = if compressed_blob.len() < metadata_blob.len() {
2221 (metadata_codec, compressed_blob)
2222 } else {
2223 (limnifs_core::codec::CODEC_STORE, metadata_blob.clone())
2224 };
2225
2226 let externalize_at = self
2230 .metadata_externalize_threshold
2231 .min(limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES as usize);
2232 let (metadata_sidecar, inline_data, metadata_locator_count) =
2233 if on_wire_blob.len() > externalize_at {
2234 let h = hash_section(&on_wire_blob);
2241 let mut h8 = String::with_capacity(8);
2242 for b in &h[..4] {
2243 h8.push_str(&format!("{b:02x}"));
2244 }
2245 let locator = format!("file:metadata-{h8}.bin");
2246 let sidecar = MetadataSidecar {
2247 bytes: on_wire_blob.clone(),
2248 locator,
2249 };
2250 (Some(sidecar), None, 1u32)
2251 } else {
2252 (None, Some(on_wire_blob.clone()), 0u32)
2253 };
2254
2255 let mut manifest = Vec::new();
2256
2257 let header_start = manifest.len();
2258 manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
2259 let header_end = manifest.len();
2260
2261 let flags_start = manifest.len();
2262 manifest.push(FEATURE_FLAGS_SECTION_VERSION);
2263 manifest.extend_from_slice(&0u32.to_le_bytes());
2264 let flags_end = manifest.len();
2265
2266 let meta_ref_start = manifest.len();
2269 manifest.push(METADATA_REFERENCE_SECTION_VERSION_2);
2270 manifest.extend_from_slice(&metadata_hash);
2271 manifest.extend_from_slice(&uncompressed_len.to_le_bytes());
2272 manifest.push(on_wire_codec);
2273 manifest.extend_from_slice(&metadata_locator_count.to_le_bytes());
2274 if let Some(sidecar) = &metadata_sidecar {
2275 let loc_bytes = sidecar.locator.as_bytes();
2276 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
2277 manifest.extend_from_slice(&loc_len.to_le_bytes());
2278 manifest.extend_from_slice(loc_bytes);
2279 }
2280 match &inline_data {
2281 Some(blob) => {
2282 let inline_len = u32::try_from(blob.len()).expect("metadata fits u32");
2283 manifest.extend_from_slice(&inline_len.to_le_bytes());
2284 manifest.extend_from_slice(blob);
2285 }
2286 None => {
2287 manifest.extend_from_slice(&0u32.to_le_bytes());
2288 }
2289 }
2290 let meta_ref_end = manifest.len();
2291
2292 let slab_index_start = manifest.len();
2293 manifest.push(SLAB_INDEX_SECTION_VERSION);
2294 manifest.extend_from_slice(&u32::try_from(slabs.len()).unwrap().to_le_bytes());
2295 for slab in &slabs {
2296 manifest.extend_from_slice(&slab.id.to_bytes());
2297 manifest.extend_from_slice(&1u32.to_le_bytes());
2298 let loc_bytes = slab.locator.as_bytes();
2299 let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
2300 manifest.extend_from_slice(&loc_len.to_le_bytes());
2301 manifest.extend_from_slice(loc_bytes);
2302 }
2303 let slab_index_end = manifest.len();
2304
2305 let history_start = manifest.len();
2306 manifest.push(HISTORY_SECTION_VERSION);
2307 manifest.extend_from_slice(&1u32.to_le_bytes());
2308 manifest.push(0x01);
2309 manifest.extend_from_slice(&0u64.to_le_bytes());
2310 manifest.extend_from_slice(&0u32.to_le_bytes());
2311 manifest.extend_from_slice(&0u32.to_le_bytes());
2312 let history_end = manifest.len();
2313
2314 let profile_desc_start = manifest.len();
2319 if let Some(ref name) = self.profile_name {
2320 let desc = limnifs_core::profile_descriptor::ProfileDescriptor {
2321 version: limnifs_core::profile_descriptor::PROFILE_DESCRIPTOR_SECTION_VERSION,
2322 profile_name: Some(name.clone()),
2323 blake3_hashing: true,
2324 cross_file_dedup: true,
2325 content_classification: !self.categorizers_disabled,
2326 integrity_verify: true,
2327 read_write: self.rw_mode,
2328 auto_turnover: self.auto_turnover,
2329 };
2330 limnifs_core::profile_descriptor::encode_profile_descriptor(&desc, &mut manifest);
2331 }
2332 let profile_desc_end = manifest.len();
2333
2334 if !self.trained_dicts_by_class.is_empty() {
2340 let dicts: Vec<_> = self
2341 .trained_dicts_by_class
2342 .values()
2343 .map(|d| limnifs_core::dictionary_section::Dictionary {
2344 codec_id: d.codec,
2345 class_id: d.id,
2346 data: d.content.clone(),
2347 })
2348 .collect();
2349 let section = limnifs_core::dictionary_section::DictionarySection {
2350 version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
2351 dicts,
2352 };
2353 limnifs_core::dictionary_section::encode_dictionary_section(§ion, &mut manifest);
2354 }
2355
2356 let dictionary_end = manifest.len();
2357
2358 let delta_linkage_hash = if let Some(base_root) = self.base_root {
2364 let delta_start = manifest.len();
2365 manifest.push(limnifs_core::delta_linkage::DELTA_LINKAGE_SECTION_VERSION);
2371 manifest.extend_from_slice(&base_root);
2372 manifest.extend_from_slice(&0u32.to_le_bytes());
2373 hash_section(&manifest[delta_start..])
2374 } else {
2375 hash_empty_section()
2376 };
2377 let _ = dictionary_end;
2378
2379 let hashes = SectionHashes {
2380 metadata: metadata_hash,
2381 format_header: hash_section(&manifest[header_start..header_end]),
2382 feature_flags: hash_section(&manifest[flags_start..flags_end]),
2383 metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
2384 slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
2385 crypto_params: hash_empty_section(),
2386 ec_params: hash_empty_section(),
2387 dms_policy: hash_empty_section(),
2388 delta_linkage: delta_linkage_hash,
2389 history: hash_section(&manifest[history_start..history_end]),
2390 };
2402 let merkle_root = compute_merkle_root(&hashes);
2403
2404 WriteArtifact {
2405 bytes: manifest,
2406 merkle_root,
2407 slabs,
2408 metadata_sidecar,
2409 inode_count,
2410 file_count: self.file_count,
2411 dir_count,
2412 drop_count,
2413 root_inode_number: self.root_inode_number,
2414 }
2415 }
2416
2417 fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
2418 out.extend_from_slice(&inode.number.to_le_bytes());
2419 out.extend_from_slice(&inode.mode.to_le_bytes());
2420 out.extend_from_slice(&inode.uid.to_le_bytes());
2421 out.extend_from_slice(&inode.gid.to_le_bytes());
2422 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
2423 out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
2424 out.extend_from_slice(&1u32.to_le_bytes());
2425 match &inode.content {
2426 PendingContent::Inline(data) => {
2427 let h = hash_section(data);
2428 if let Some(&idx) = self.shared_inline_map.get(&h) {
2429 out.push(INODE_FLAG_INLINE_DATA | INODE_FLAG_SHARED_INLINE);
2431 out.extend_from_slice(&(idx as u32).to_le_bytes());
2432 } else {
2433 out.push(INODE_FLAG_INLINE_DATA);
2434 let len = u32::try_from(data.len()).expect("data fits u32");
2435 out.extend_from_slice(&len.to_le_bytes());
2436 out.extend_from_slice(data);
2437 }
2438 }
2439 PendingContent::DropBacked { file_len, slices } => {
2440 out.push(0x00);
2441 let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
2442 out.extend_from_slice(&slice_count.to_le_bytes());
2443 for slice in slices {
2444 out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
2445 out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
2446 out.extend_from_slice(&slice.drop_id);
2447 out.extend_from_slice(&0u32.to_le_bytes());
2449 let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
2453 .expect("slice range fits u32");
2454 out.extend_from_slice(&drop_byte_len.to_le_bytes());
2455 }
2456 let _ = file_len;
2457 }
2458 PendingContent::Symlink(target) => {
2459 out.push(0x00);
2463 let t = target.as_bytes();
2464 let len = u32::try_from(t.len()).expect("target fits u32");
2465 out.extend_from_slice(&len.to_le_bytes());
2466 out.extend_from_slice(t);
2467 }
2468 PendingContent::Directory(entries) => {
2469 out.push(0x00);
2470 let node = self
2471 .dir_nodes
2472 .iter()
2473 .find(|n| n.entries == *entries)
2474 .expect("directory node must exist");
2475 out.extend_from_slice(&node.hash);
2476 }
2477 }
2478 }
2479}
2480
2481fn sidecar_name(locator: &str) -> Result<&str, WriteError> {
2487 limnifs_core::locator::local_sidecar_name(locator)
2488 .map_err(|e| WriteError::Io(std::io::Error::other(format!("{e}"))))
2489}
2490
2491fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
2492 let mut bytes = Vec::new();
2493 bytes.push(1u8);
2494 let count = u32::try_from(entries.len()).expect("entry count fits u32");
2495 bytes.extend_from_slice(&count.to_le_bytes());
2496 for (name, inode_number, entry_type) in entries {
2497 let name_bytes = name.as_bytes();
2498 let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
2499 bytes.extend_from_slice(&name_len.to_le_bytes());
2500 bytes.extend_from_slice(name_bytes);
2501 bytes.extend_from_slice(&inode_number.to_le_bytes());
2502 bytes.push(*entry_type);
2503 }
2504 let hash = hash_section(&bytes);
2505 DirNode {
2506 entries: entries.to_vec(),
2507 bytes,
2508 hash,
2509 }
2510}
2511
2512fn pack_slabs(drops: &[PendingDrop]) -> Vec<SlabArtifact> {
2521 let local_drops: Vec<&PendingDrop> = drops
2526 .iter()
2527 .filter(|d| d.codec != limnifs_core::codec::CODEC_REFERENCED)
2528 .collect();
2529 if local_drops.is_empty() {
2530 return Vec::new();
2531 }
2532
2533 let max_content = MAX_SLAB_TOTAL_BYTES.saturating_sub(SLAB_HEADER_LEN);
2534
2535 let mut slab_groups: Vec<Vec<&PendingDrop>> = Vec::new();
2540 let mut current: Vec<&PendingDrop> = Vec::new();
2541 let mut current_size: usize = 0;
2542
2543 for drop in &local_drops {
2544 let footprint = drop.slab_footprint();
2545 if !current.is_empty() && current_size + footprint > max_content {
2546 slab_groups.push(std::mem::take(&mut current));
2547 current_size = 0;
2548 }
2549 current.push(*drop);
2550 current_size += footprint;
2551 }
2552 if !current.is_empty() {
2553 slab_groups.push(current);
2554 }
2555
2556 use rayon::prelude::*;
2562 slab_groups
2563 .par_iter()
2564 .enumerate()
2565 .map(|(ordinal, group)| {
2566 let ordinal_u64 = u64::try_from(ordinal).expect("slab count fits u64");
2567 encode_slab(ordinal_u64, group)
2568 })
2569 .collect()
2570}
2571
2572fn encode_slab(ordinal: u64, drops: &[&PendingDrop]) -> SlabArtifact {
2576 const DROP_RECORD_LEN: usize = 50;
2583 let mut drop_records = Vec::with_capacity(drops.len() * DROP_RECORD_LEN);
2584 let mut solid_window = Vec::new();
2585 let mut drop_ids = Vec::with_capacity(drops.len());
2586 let mut offset_in_window: u32 = 0;
2587
2588 for drop in drops {
2589 let plaintext_len = drop.plaintext_len_value();
2590 let window_len = drop.len_in_window();
2591 drop_records.extend_from_slice(&drop.id);
2592 drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
2593 drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
2595 drop_records.push(0x00); drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
2597 drop_records.extend_from_slice(&window_len.to_le_bytes());
2598 drop_records.push(drop.dict_id); drop_records.push(drop.flags); solid_window.extend_from_slice(&drop.compressed);
2601 drop_ids.push(drop.id);
2602 offset_in_window = offset_in_window
2603 .checked_add(window_len)
2604 .expect("slab window size fits u32");
2605 }
2606
2607 let slab_content = [&drop_records[..], &solid_window[..]].concat();
2608 let slab_hash = hash_section(&slab_content);
2609 let slab_id = SlabId::new(ordinal, slab_hash);
2610
2611 let total_length = SLAB_HEADER_LEN + slab_content.len();
2612 let mut slab_bytes = Vec::with_capacity(total_length);
2613 slab_bytes.extend_from_slice(b"LIM1");
2614 slab_bytes.extend_from_slice(&1u16.to_le_bytes()); slab_bytes.extend_from_slice(&slab_id.to_bytes());
2616 slab_bytes.extend_from_slice(
2617 &u64::try_from(total_length)
2618 .unwrap_or(u64::MAX)
2619 .to_le_bytes(),
2620 );
2621 slab_bytes.push(0x00);
2622 slab_bytes.push(0x00);
2623 slab_bytes.extend_from_slice(&slab_content);
2624
2625 let mut h8 = String::with_capacity(8);
2629 for b in &slab_id.hash[..4] {
2630 h8.push_str(&format!("{b:02x}"));
2631 }
2632 let locator = format!("file:slab-{ordinal}-{h8}.bin");
2633
2634 SlabArtifact {
2635 id: slab_id,
2636 bytes: slab_bytes,
2637 locator,
2638 drop_ids,
2639 }
2640}
2641
2642#[cfg(test)]
2643fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
2644 let mut state = seed;
2645 let mut out = Vec::with_capacity(count);
2646 for _ in 0..count {
2647 state = state
2648 .wrapping_mul(6_364_136_223_846_793_005)
2649 .wrapping_add(1_442_695_040_888_963_407);
2650 out.push(u8::try_from(state >> 56).expect("fits u8"));
2651 }
2652 out
2653}
2654
2655#[cfg(test)]
2656mod tests {
2657 use super::*;
2658 use limnifs_core::ManifestCursor;
2659
2660 #[test]
2661 fn write_stream_packs_single_named_stream() {
2662 let temp = std::env::temp_dir().join(format!(
2666 "limnifs-write-stream-test-{}-{}",
2667 std::process::id(),
2668 std::time::SystemTime::now()
2669 .duration_since(std::time::UNIX_EPOCH)
2670 .unwrap()
2671 .as_nanos()
2672 ));
2673 std::fs::create_dir_all(&temp).expect("create temp dir");
2674
2675 let content = b"stream test content line\n".repeat(10_000); let cursor = std::io::Cursor::new(content.clone());
2677 let config = WriteConfig::default_v0_1();
2678 let artifact = write_stream("streamed.txt", cursor, &config).expect("write_stream");
2679
2680 assert!(!artifact.bytes.is_empty(), "manifest bytes non-empty");
2681 assert!(!artifact.slabs.is_empty(), "at least one slab produced");
2682 let total_drop_bytes: usize = artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2687 assert!(total_drop_bytes > 0, "drops non-empty");
2688
2689 let _ = std::fs::remove_dir_all(&temp);
2690 }
2691
2692 #[test]
2693 fn write_layer_references_base_drops() {
2694 let temp = std::env::temp_dir().join(format!(
2700 "limnifs-write-layer-test-{}-{}",
2701 std::process::id(),
2702 std::time::SystemTime::now()
2703 .duration_since(std::time::UNIX_EPOCH)
2704 .unwrap()
2705 .as_nanos()
2706 ));
2707 std::fs::create_dir_all(&temp).expect("create temp dir");
2708
2709 let base_dir = temp.join("base");
2711 std::fs::create_dir_all(&base_dir).expect("base dir");
2712 let text = b"layer test content line\n".repeat(50_000); std::fs::write(base_dir.join("shared.txt"), &text).expect("write shared");
2714 std::fs::write(base_dir.join("base-only.txt"), b"only in base").expect("write base-only");
2715
2716 let config = WriteConfig::default_v0_1();
2717 let base_artifact = write_directory_with_config(&base_dir, &config).expect("base");
2718
2719 let base_manifest = temp.join("base.lim");
2720 std::fs::write(&base_manifest, &base_artifact.bytes).expect("write base manifest");
2721 for slab in &base_artifact.slabs {
2722 let slab_name = sidecar_name(&slab.locator).expect("slab locator");
2723 std::fs::write(temp.join(slab_name), &slab.bytes).expect("write base slab");
2724 }
2725
2726 let layer_dir = temp.join("layer");
2728 std::fs::create_dir_all(&layer_dir).expect("layer dir");
2729 std::fs::write(layer_dir.join("shared.txt"), &text).expect("write shared in layer");
2730 std::fs::write(layer_dir.join("new.txt"), b"fresh content in layer").expect("write new");
2731
2732 let layer_artifact = write_layer(&base_manifest, &layer_dir, &config).expect("layer");
2733
2734 let layer_slab_bytes: usize = layer_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2737 let base_slab_bytes: usize = base_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2738 assert!(
2739 layer_slab_bytes < base_slab_bytes / 4,
2740 "layer slabs ({}) should be much smaller than base ({}) — layering failed",
2741 layer_slab_bytes,
2742 base_slab_bytes
2743 );
2744
2745 let base_root = base_artifact.merkle_root.as_bytes();
2748 assert!(
2749 layer_artifact
2750 .bytes
2751 .windows(32)
2752 .any(|w| w == base_root.as_slice()),
2753 "layer manifest must contain base's ManifestRoot bytes"
2754 );
2755
2756 let _ = std::fs::remove_dir_all(&temp);
2757 }
2758
2759 #[test]
2760 fn tournament_short_circuits_on_highly_compressible_chunk() {
2761 let chunk = b"hello world ".repeat(500);
2764 let tunables = limnifs_core::codec::CodecTunables::default();
2765 let tournament = TournamentSpec {
2766 codec_ids: vec![
2767 limnifs_core::codec::CODEC_LZ4,
2768 limnifs_core::codec::CODEC_BROTLI,
2769 ],
2770 min_size: 16,
2771 skip_for_binary: false,
2772 short_circuit_permille: 250,
2773 };
2774 let (codec_id, compressed) = compress_chunk_with_tournament(
2775 &chunk,
2776 classifier::Class::Text,
2777 limnifs_core::codec::CODEC_BROTLI,
2778 limnifs_core::codec::CODEC_LZ4,
2779 &tunables,
2780 &tournament,
2781 );
2782 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2783 assert!(compressed.len() < chunk.len());
2784 }
2785
2786 #[test]
2787 fn tournament_runs_all_codecs_when_short_circuit_disabled() {
2788 let chunk = b"hello world ".repeat(500);
2794 let tunables = limnifs_core::codec::CodecTunables::default();
2795 let tournament = TournamentSpec {
2796 codec_ids: vec![
2797 limnifs_core::codec::CODEC_LZ4,
2798 limnifs_core::codec::CODEC_BROTLI,
2799 limnifs_core::codec::CODEC_ZSTD,
2800 ],
2801 min_size: 16,
2802 skip_for_binary: false,
2803 short_circuit_permille: 0,
2804 };
2805 let (codec_id, compressed) = compress_chunk_with_tournament(
2806 &chunk,
2807 classifier::Class::Text,
2808 limnifs_core::codec::CODEC_BROTLI,
2809 limnifs_core::codec::CODEC_LZ4,
2810 &tunables,
2811 &tournament,
2812 );
2813 assert!(
2817 codec_id == limnifs_core::codec::CODEC_ZSTD
2818 || codec_id == limnifs_core::codec::CODEC_BROTLI,
2819 "expected ZSTD or Brotli to win, got codec {codec_id}"
2820 );
2821 assert!(compressed.len() < chunk.len());
2822 }
2823
2824 #[test]
2825 fn tournament_skips_for_binary_when_configured() {
2826 let chunk = vec![0u8; 4096];
2827 let tunables = limnifs_core::codec::CodecTunables::default();
2828 let tournament = TournamentSpec {
2829 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2830 min_size: 16,
2831 skip_for_binary: true,
2832 short_circuit_permille: 250,
2833 };
2834 let (codec_id, _compressed) = compress_chunk_with_tournament(
2835 &chunk,
2836 classifier::Class::Binary,
2837 limnifs_core::codec::CODEC_BROTLI,
2838 limnifs_core::codec::CODEC_LZ4,
2839 &tunables,
2840 &tournament,
2841 );
2842 assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2844 }
2845
2846 #[test]
2847 fn tournament_small_chunk_uses_preferred_codec() {
2848 let chunk = b"tiny";
2849 let tunables = limnifs_core::codec::CodecTunables::default();
2850 let tournament = TournamentSpec {
2851 codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2852 min_size: 1024,
2853 skip_for_binary: false,
2854 short_circuit_permille: 0,
2855 };
2856 let (codec_id, _compressed) = compress_chunk_with_tournament(
2857 chunk,
2858 classifier::Class::Text,
2859 limnifs_core::codec::CODEC_BROTLI,
2860 limnifs_core::codec::CODEC_LZ4,
2861 &tunables,
2862 &tournament,
2863 );
2864 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2866 }
2867
2868 #[test]
2869 fn tournament_falls_back_to_store_when_no_codec_compresses() {
2870 let chunk = pseudo_random_bytes(42, 4096);
2874 let tunables = limnifs_core::codec::CodecTunables::default();
2875 let tournament = TournamentSpec {
2876 codec_ids: vec![limnifs_core::codec::CODEC_LZ4],
2877 min_size: 16,
2878 skip_for_binary: false,
2879 short_circuit_permille: 0,
2880 };
2881 let (codec_id, compressed) = compress_chunk_with_tournament(
2882 &chunk,
2883 classifier::Class::Binary,
2884 limnifs_core::codec::CODEC_BROTLI,
2885 limnifs_core::codec::CODEC_LZ4,
2886 &tunables,
2887 &tournament,
2888 );
2889 assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2890 assert_eq!(compressed.len(), chunk.len());
2891 }
2892
2893 #[test]
2894 fn dictionaries_enabled_emits_dictionary_section_when_enough_samples() {
2895 let temp = std::env::temp_dir().join(format!(
2900 "limnifs-write-test-{}-dict-{}",
2901 std::process::id(),
2902 std::time::SystemTime::now()
2903 .duration_since(std::time::UNIX_EPOCH)
2904 .map(|d| d.as_nanos() as u64)
2905 .unwrap_or(0),
2906 ));
2907 let _ = std::fs::remove_dir_all(&temp);
2908 std::fs::create_dir_all(&temp).expect("mkdir");
2909
2910 for i in 0..200 {
2913 let content = format!(
2915 "function test_case_{i}() {{ return constant + {i}; }}\n\
2916 // shared comment line {i}\n\
2917 struct Foo {{ x: i32 }} // type {i}\n"
2918 )
2919 .repeat(5);
2920 let path = temp.join(format!("file_{i:04}.txt"));
2921 std::fs::write(&path, content.as_bytes()).expect("write");
2922 }
2923
2924 let mut config = crate::profile::balanced();
2925 config.defaults.text_codec = "zstd".into();
2927 config.defaults.metadata_codec = "zstd".into();
2931 config.dictionaries.enabled = true;
2932 config.dictionaries.min_class_size = 50;
2933 config.dictionaries.max_dict_size = 8192;
2934
2935 let artifact = write_directory_with_config(&temp, &config).expect("write");
2936 std::fs::remove_dir_all(&temp).ok();
2937
2938 let mut cursor = ManifestCursor::new(&artifact.bytes);
2943 let _ = limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2944 let _ = limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2945 let _ = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta_ref");
2946 let _ = limnifs_core::parse_slab_index(&mut cursor).expect("slab_index");
2947 let _ = limnifs_core::parse_history(&mut cursor).expect("history");
2948 let _remaining = cursor.remaining_len();
2951 }
2952
2953 #[test]
2954 fn write_empty_directory() {
2955 let temp =
2956 std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
2957 std::fs::create_dir_all(&temp).expect("create temp dir");
2958 let artifact = write_directory(&temp).expect("write succeeds");
2959 std::fs::remove_dir_all(&temp).ok();
2960 assert!(artifact.inode_count >= 1);
2961 assert_eq!(artifact.file_count, 0);
2962 assert_eq!(artifact.dir_count, 1);
2963 assert!(artifact.slabs.is_empty());
2964 }
2965
2966 #[test]
2967 fn write_small_file_inline() {
2968 let temp =
2969 std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
2970 std::fs::create_dir_all(&temp).expect("create temp dir");
2971 std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
2972 let artifact = write_directory(&temp).expect("write succeeds");
2973 std::fs::remove_dir_all(&temp).ok();
2974 assert_eq!(artifact.file_count, 1);
2975 assert!(artifact.slabs.is_empty());
2976 assert_eq!(artifact.drop_count, 0);
2977 }
2978
2979 #[test]
2980 fn write_large_file_uses_slab() {
2981 let temp =
2982 std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
2983 std::fs::create_dir_all(&temp).expect("create temp dir");
2984 let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
2985 std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
2986 let artifact = write_directory(&temp).expect("write succeeds");
2987 std::fs::remove_dir_all(&temp).ok();
2988 assert_eq!(artifact.drop_count, 1);
2989 assert_eq!(artifact.slabs.len(), 1);
2990 }
2991
2992 #[test]
2993 fn write_mixed_inline_and_large() {
2994 let temp =
2995 std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
2996 std::fs::create_dir_all(&temp).expect("create temp dir");
2997 std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
2998 std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
2999 .expect("write large");
3000 let artifact = write_directory(&temp).expect("write succeeds");
3001 std::fs::remove_dir_all(&temp).ok();
3002 assert_eq!(artifact.file_count, 2);
3003 assert_eq!(artifact.drop_count, 1);
3004 assert_eq!(artifact.slabs.len(), 1);
3005 }
3006
3007 #[test]
3008 fn deduplicates_identical_large_files() {
3009 let temp =
3010 std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
3011 std::fs::create_dir_all(&temp).expect("create temp dir");
3012 let data = vec![0x77u8; INLINE_THRESHOLD + 10];
3013 std::fs::write(temp.join("a.bin"), &data).expect("write a");
3014 std::fs::write(temp.join("b.bin"), &data).expect("write b");
3015 let artifact = write_directory(&temp).expect("write succeeds");
3016 std::fs::remove_dir_all(&temp).ok();
3017 assert_eq!(artifact.drop_count, 1);
3018 }
3019
3020 #[test]
3021 fn write_and_verify_roundtrip() {
3022 let temp = std::env::temp_dir().join(format!(
3023 "limnifs-write-test-{}-roundtrip",
3024 std::process::id()
3025 ));
3026 std::fs::create_dir_all(&temp).expect("create temp dir");
3027 std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
3028 std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
3029 std::fs::create_dir_all(temp.join("sub")).expect("create sub");
3030 std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
3031 let artifact = write_directory(&temp).expect("write succeeds");
3032 std::fs::remove_dir_all(&temp).ok();
3033 assert_eq!(artifact.file_count, 3);
3034 assert_eq!(artifact.dir_count, 2);
3035
3036 let mut cursor = ManifestCursor::new(&artifact.bytes);
3037 limnifs_core::parse_manifest_header(&mut cursor).expect("header");
3038 limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
3039 let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
3040 assert!(meta_ref.is_inlined());
3041 let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
3042 assert_eq!(slab_index.len(), 0);
3043 limnifs_core::parse_history(&mut cursor).expect("history");
3044 }
3045
3046 #[test]
3047 fn write_deterministic() {
3048 let temp =
3049 std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
3050 std::fs::create_dir_all(&temp).expect("create temp dir");
3051 std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
3052
3053 let a1 = write_directory(&temp).expect("first write");
3054 let a2 = write_directory(&temp).expect("second write");
3055 std::fs::remove_dir_all(&temp).ok();
3056
3057 assert_eq!(a1.bytes, a2.bytes);
3058 assert_eq!(a1.merkle_root, a2.merkle_root);
3059 }
3060
3061 #[test]
3062 fn slab_parses_correctly() {
3063 let temp =
3064 std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
3065 std::fs::create_dir_all(&temp).expect("create temp dir");
3066 std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
3067 .expect("write big");
3068 let artifact = write_directory(&temp).expect("write succeeds");
3069 std::fs::remove_dir_all(&temp).ok();
3070
3071 let slab_bytes = &artifact.slabs[0].bytes;
3072 let mut cursor = ManifestCursor::new(slab_bytes);
3073 let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
3074 assert_eq!(
3075 slab_header.format_version,
3076 limnifs_core::slab::SLAB_FORMAT_VERSION
3077 );
3078 assert!(!slab_header.is_sealed());
3079 assert!(!slab_header.has_erasure_coding());
3080
3081 let drop_record =
3082 limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
3083 assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
3084 }
3085
3086 #[test]
3087 fn fastcdc_produces_multiple_chunks_for_large_files() {
3088 let temp = std::env::temp_dir().join(format!(
3091 "limnifs-write-test-{}-cdc-multi",
3092 std::process::id()
3093 ));
3094 std::fs::create_dir_all(&temp).expect("create temp dir");
3095 let data = pseudo_random_bytes(42, 1024 * 1024);
3096 std::fs::write(temp.join("big.bin"), &data).expect("write big");
3097 let artifact = write_directory(&temp).expect("write succeeds");
3098 std::fs::remove_dir_all(&temp).ok();
3099 assert!(
3100 artifact.drop_count > 1,
3101 "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
3102 artifact.drop_count
3103 );
3104 }
3105
3106 #[test]
3107 fn fastcdc_deduplicates_shared_substrings() {
3108 let temp = std::env::temp_dir().join(format!(
3112 "limnifs-write-test-{}-cdc-dedup",
3113 std::process::id()
3114 ));
3115 std::fs::create_dir_all(&temp).expect("create temp dir");
3116 let shared = pseudo_random_bytes(7, 512 * 1024);
3117 let mut a = Vec::with_capacity(shared.len() + 1024);
3118 a.extend_from_slice(&pseudo_random_bytes(1, 1024));
3119 a.extend_from_slice(&shared);
3120 let mut b = Vec::with_capacity(shared.len() + 2048);
3121 b.extend_from_slice(&pseudo_random_bytes(2, 2048));
3122 b.extend_from_slice(&shared);
3123 std::fs::write(temp.join("a.bin"), &a).expect("write a");
3124 std::fs::write(temp.join("b.bin"), &b).expect("write b");
3125
3126 let temp_a = std::env::temp_dir().join(format!(
3128 "limnifs-write-test-{}-cdc-dedup-a",
3129 std::process::id()
3130 ));
3131 std::fs::create_dir_all(&temp_a).expect("create temp_a");
3132 std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
3133 let artifact_a = write_directory(&temp_a).expect("a writes");
3134 std::fs::remove_dir_all(&temp_a).ok();
3135
3136 let temp_b = std::env::temp_dir().join(format!(
3137 "limnifs-write-test-{}-cdc-dedup-b",
3138 std::process::id()
3139 ));
3140 std::fs::create_dir_all(&temp_b).expect("create temp_b");
3141 std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
3142 let artifact_b = write_directory(&temp_b).expect("b writes");
3143 std::fs::remove_dir_all(&temp_b).ok();
3144
3145 let artifact_both = write_directory(&temp).expect("both write");
3146 std::fs::remove_dir_all(&temp).ok();
3147
3148 let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
3149 assert!(
3150 artifact_both.drop_count < sum_alone,
3151 "expected dedup win: both together = {} drops, sum alone = {} drops",
3152 artifact_both.drop_count,
3153 sum_alone
3154 );
3155 }
3156
3157 #[test]
3158 fn slab_splits_when_content_exceeds_ceiling() {
3159 let temp =
3165 std::env::temp_dir().join(format!("limnifs-write-test-{}-split", std::process::id()));
3166 std::fs::create_dir_all(&temp).expect("create temp dir");
3167 for i in 0..7u32 {
3168 let data = pseudo_random_bytes(u64::from(i), 10 * 1024 * 1024);
3170 std::fs::write(temp.join(format!("big-{i}.bin")), &data).expect("write big");
3171 }
3172 let artifact = write_directory(&temp).expect("write succeeds");
3173 std::fs::remove_dir_all(&temp).ok();
3174
3175 assert!(
3177 artifact.slabs.len() >= 2,
3178 "expected at least 2 slabs for 70 MiB of incompressible data, got {}",
3179 artifact.slabs.len()
3180 );
3181 for slab in &artifact.slabs {
3182 assert!(
3183 slab.bytes.len() <= MAX_SLAB_TOTAL_BYTES,
3184 "slab {} is {} bytes (> {} ceiling)",
3185 slab.id.ordinal,
3186 slab.bytes.len(),
3187 MAX_SLAB_TOTAL_BYTES,
3188 );
3189 }
3190 let total_drop_ids: usize = artifact.slabs.iter().map(|s| s.drop_ids.len()).sum();
3192 assert_eq!(
3193 total_drop_ids, artifact.drop_count,
3194 "drop_ids count across slabs must match WriteArtifact.drop_count",
3195 );
3196 }
3197}