Skip to main content

limnifs_write/
lib.rs

1//! `LimniFS` writer pipeline — directory tree to `.lim` image.
2//!
3//! The writer takes a real directory tree and produces a valid `.lim`
4//! manifest artifact with inlined metadata. Files at or below the
5//! inline threshold (4 KiB) are stored as inline data in their inodes;
6//! larger files are stored as drops packed into a single slab.
7//!
8//! ## Usage
9//!
10//! ```no_run
11//! use std::path::Path;
12//! use limnifs_write::write_directory;
13//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let artifact = write_directory(Path::new("/path/to/dir"))?;
15//! std::fs::write("output.lim", &artifact.bytes)?;
16//! # Ok(())
17//! # }
18//! ```
19
20#![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 rw;
33#[cfg(feature = "sparse-index")]
34pub mod sparse_index;
35pub mod turnover;
36
37pub use config::{
38    profile, CategorizerConfig, ChunkingConfig, CodecRegistry, CodecTunables, Defaults,
39    DictionaryConfig, EncryptionConfig, TournamentConfig, WriteConfig,
40};
41
42use std::collections::{HashMap, HashSet};
43use std::path::{Path, PathBuf};
44
45use crate::chunker::FastCDC;
46use limnifs_core::codec::CODEC_REFERENCED;
47use limnifs_core::slab_store::SlabStore;
48use limnifs_core::{
49    compute_merkle_root, hash_empty_section, hash_section, parse_manifest_header, parse_slab_index,
50    ManifestCursor, ManifestHeader, SectionHashes, FEATURE_FLAGS_SECTION_VERSION,
51    HISTORY_SECTION_VERSION, INODE_FLAG_INLINE_DATA, INODE_FLAG_SHARED_INLINE,
52    METADATA_REFERENCE_SECTION_VERSION_2, SLAB_INDEX_SECTION_VERSION,
53};
54use limnifs_format::{ManifestRoot, SlabId};
55
56/// Inline-data threshold: files at or below this size get inline data
57/// in their inode. Larger files are stored as drops in a slab.
58pub const INLINE_THRESHOLD: usize = 4096;
59
60/// Above this size, mmap the input file instead of `std::fs::read`-ing
61/// it into a `Vec<u8>`. Keeps peak RSS bounded when packing huge files
62/// (multi-GiB source trees, ML models). Crossover is around 1 MiB on
63/// most filesystems — below that the syscall + VMA setup costs more
64/// than the read.
65pub const MMAP_READ_THRESHOLD: usize = 1024 * 1024;
66
67/// Maximum file size for the whole-file categorizer path. Files above
68/// this threshold use FastCDC chunking even when a categorizer claims
69/// them, enabling rayon parallelism across chunks. The categorizer's
70/// codec is still used per-chunk when possible.
71pub const WHOLE_FILE_MAX_SIZE: usize = 64 * 1024 * 1024;
72
73/// Maximum total length of a single slab file (header + content).
74/// Matches the reader's `DEFAULT_SLAB_MAX_BYTES` (spec §3.1) minus a
75/// safety margin so a slab that is full but not yet flushed cannot
76/// overrun the reader ceiling on the next drop.
77pub const MAX_SLAB_TOTAL_BYTES: usize = 60 * 1024 * 1024;
78
79/// Width of the slab header (magic + version + `SlabId` + `total_length` +
80/// `ec_descriptor` + `crypto_hint`). Must agree with
81/// `limnifs_core::slab::SLAB_HEADER_LEN`.
82const SLAB_HEADER_LEN: usize = 56;
83
84/// Default threshold at which the writer externalises the metadata
85/// blob to a sidecar file instead of inlining it in the manifest.
86/// Derived from the reader's inline ceiling
87/// (`limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES`,
88/// 1 MiB per spec §5.3) minus headroom, so the two constants cannot
89/// silently drift apart. Override per image via
90/// `WriteConfig::defaults::metadata_externalize_threshold` (issue #187).
91pub const METADATA_EXTERNALIZE_THRESHOLD: usize =
92    limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES as usize - 24 * 1024;
93
94/// Metadata-blob size above which the writer steps Brotli quality
95/// down to `METADATA_LARGE_BLOB_QUALITY`. Below this, q5's cost is
96/// negligible; above it, q5 starts to dominate create time on big
97/// inode trees (e.g. the 50 K-file tiny-files dataset).
98pub const METADATA_LARGE_BLOB_THRESHOLD: usize = 256 * 1024;
99
100/// Brotli quality for small metadata blobs (≤ `METADATA_LARGE_BLOB_THRESHOLD`).
101/// Best ratio; cost is in the noise on small inputs.
102pub const METADATA_SMALL_BLOB_QUALITY: i32 = 5;
103
104/// Brotli quality for large metadata blobs. q2 is much faster than q5
105/// on multi-MiB inputs; ratio on highly compressible inode data is
106/// within 5–10% of q5 (often identical) because metadata is dominated
107/// by long runs of repeated patterns.
108pub const METADATA_LARGE_BLOB_QUALITY: i32 = 2;
109
110/// One slab produced by the writer. The slab ordinal in `id` matches
111/// the slab's position in `WriteArtifact::slabs`.
112#[derive(Clone, Debug)]
113pub struct SlabArtifact {
114    pub id: SlabId,
115    pub bytes: Vec<u8>,
116    pub locator: String,
117    /// `DropIds` contained in this slab, in slab order. Used by callers
118    /// that need to know which slab holds which drop without re-parsing
119    /// the slab bytes.
120    pub drop_ids: Vec<[u8; 32]>,
121}
122
123/// Externalized metadata sidecar, present when the metadata blob
124/// exceeds [`METADATA_EXTERNALIZE_THRESHOLD`]. Callers must write
125/// `bytes` to `locator` next to the manifest file.
126#[derive(Clone, Debug)]
127pub struct MetadataSidecar {
128    pub bytes: Vec<u8>,
129    pub locator: String,
130}
131
132/// Result of writing a directory tree.
133#[derive(Clone, Debug)]
134pub struct WriteArtifact {
135    pub bytes: Vec<u8>,
136    pub merkle_root: ManifestRoot,
137    /// All slabs produced by the writer, in slab-ordinal order. Empty
138    /// when the source tree had no files > [`INLINE_THRESHOLD`].
139    pub slabs: Vec<SlabArtifact>,
140    /// External metadata sidecar, present when the metadata blob
141    /// exceeds [`METADATA_EXTERNALIZE_THRESHOLD`]. `None` means the
142    /// metadata is inlined in the manifest.
143    pub metadata_sidecar: Option<MetadataSidecar>,
144    pub inode_count: usize,
145    pub file_count: usize,
146    pub dir_count: usize,
147    pub drop_count: usize,
148    /// Inode number of the root directory (i.e. the inode that
149    /// represents the source directory itself, not a child of it).
150    /// Always a directory and always referenced by the inlined
151    /// metadata blob's directory inode table.
152    pub root_inode_number: u64,
153}
154
155impl WriteArtifact {
156    /// Convenience accessor for the single-slab case. Returns the
157    /// first slab's bytes if there is exactly one slab, else `None`.
158    /// Modern callers should iterate [`WriteArtifact::slabs`] directly.
159    #[must_use]
160    pub fn slab_bytes(&self) -> Option<&[u8]> {
161        if self.slabs.len() == 1 {
162            Some(&self.slabs[0].bytes)
163        } else {
164            None
165        }
166    }
167
168    /// Convenience accessor for the single-slab case.
169    #[must_use]
170    pub fn slab_locator(&self) -> Option<&str> {
171        if self.slabs.len() == 1 {
172            Some(&self.slabs[0].locator)
173        } else {
174            None
175        }
176    }
177}
178
179/// Error during writing.
180#[derive(Debug)]
181pub enum WriteError {
182    Io(std::io::Error),
183    /// The tree contains an entry type the writer deliberately does
184    /// not store (sockets, FIFOs, device nodes). Symlinks ARE
185    /// supported; everything else on a normal filesystem tree is
186    /// either a file, a directory, or this error.
187    UnsupportedFileType {
188        path: PathBuf,
189        kind: String,
190    },
191}
192
193impl std::fmt::Display for WriteError {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::Io(e) => write!(f, "I/O error: {e}"),
197            Self::UnsupportedFileType { path, kind } => write!(
198                f,
199                "unsupported file type ({kind}): {} — limnifs stores files, \
200                 directories, and symlinks; remove the entry or file an issue \
201                 if you need it carried",
202                path.display()
203            ),
204        }
205    }
206}
207
208impl std::error::Error for WriteError {}
209
210impl From<std::io::Error> for WriteError {
211    fn from(e: std::io::Error) -> Self {
212        Self::Io(e)
213    }
214}
215
216/// Walk a directory tree and produce a valid `.lim` manifest artifact
217/// with inlined metadata. Files at or below [`INLINE_THRESHOLD`] bytes
218/// are stored inline; larger files are packed into a single slab as
219/// content-addressed drops.
220///
221/// File contents (read, `FastCDC` chunk, `BLAKE3` hash, `LZ4` compress) are
222/// processed in parallel across `CPU` cores via `rayon`. The directory
223/// tree walk and slab assembly remain sequential so the output is
224/// deterministic.
225///
226/// # Errors
227///
228/// Returns [`WriteError::Io`] for filesystem errors.
229pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
230    write_directory_with_config(root, &WriteConfig::default_v0_1())
231}
232
233/// Pack a single named stream into a `.lim` image.
234///
235/// For callers that pipe data from a network socket, pipe, or generator
236/// and don't want to materialise the full content on disk before
237/// packing. The reader is consumed via [`FastCDC::chunk_reader`] which
238/// bounds internal buffering at `max_chunk_size + 64 KiB`.
239///
240/// The resulting image has a single root file with the given `name`
241/// (path-relative; safe to use `/` for subdirectories — they're
242/// materialised in the metadata tree).
243///
244/// # Errors
245///
246/// Returns [`WriteError::Io`] on read failure or any writer-pipeline
247/// error.
248pub fn write_stream<R: std::io::Read>(
249    name: &str,
250    reader: R,
251    config: &WriteConfig,
252) -> Result<WriteArtifact, WriteError> {
253    let mut ctx = WriteContext::new();
254    ctx.chunker = chunker_from_config(config)?;
255    ctx.categorizers_disabled = config.categorizers.is_empty();
256    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
257    ctx.auto_turnover = config.turnover_threshold > 0;
258    ctx.collect_dict_samples = config.dictionaries.enabled;
259
260    // Synthesise a single PendingFile that points to nothing on disk;
261    // we'll bypass process_file's `std::fs::read` and feed the
262    // pre-chunked bytes directly.
263    let drop_id_root = [0u8; 32]; // placeholder; replaced below
264    let pending = PendingFile {
265        path: std::path::PathBuf::from(name),
266        inode_number: 1,
267        file_len: 0, // patched below once we know the total
268        mtime_ns: 0,
269    };
270    ctx.pending_files.push(pending);
271    ctx.root_inode_number = 1;
272
273    // Chunk the stream directly via FastCDC's chunk_reader.
274    let chunker = ctx.chunker.clone();
275    let chunks = chunker.chunk_reader(reader)?;
276
277    // Total size = sum of chunk lengths.
278    let total_len: u64 = chunks.iter().map(|c| c.len() as u64).sum();
279
280    // Hash + compress each chunk. We treat each chunk as a unique drop
281    // (the stream is single-pass; cross-call dedup is left to the
282    // caller). Use the configured tournament spec.
283    let text_codec = config.text_codec_id().unwrap_or(0x04);
284    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
285    let tunables = config.to_core_tunables();
286    let classifier = ctx.classifier;
287    let registry = config
288        .codec_registry()
289        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
290    let tournament_codec_ids: Vec<u8> = config
291        .tournament
292        .codecs
293        .iter()
294        .filter_map(|n| registry.lookup_by_name(n))
295        .collect();
296    let tournament = TournamentSpec {
297        codec_ids: tournament_codec_ids,
298        min_size: config.tournament.min_size_threshold as usize,
299        skip_for_binary: config.tournament.skip_for_binary,
300        short_circuit_permille: config.tournament.short_circuit_threshold,
301    };
302
303    let mut drops: Vec<RawDrop> = Vec::with_capacity(chunks.len());
304    let mut slices: Vec<PendingSlice> = Vec::with_capacity(chunks.len());
305    let mut offset: u64 = 0;
306    for chunk in &chunks {
307        let drop_id = hash_section(chunk);
308        slices.push(PendingSlice {
309            drop_id,
310            file_byte_start: offset,
311            file_byte_end: offset + chunk.len() as u64,
312        });
313        offset += chunk.len() as u64;
314        let class = classifier.classify(chunk);
315        let (codec_id, compressed) = compress_chunk_with_tournament(
316            chunk,
317            class,
318            text_codec,
319            binary_codec,
320            &tunables,
321            &tournament,
322        );
323        drops.push((drop_id, chunk.clone(), compressed, codec_id, 0));
324    }
325    let _ = drop_id_root;
326
327    // Wire into WriteContext as a single-file result + inode.
328    let result = ChunkedFileResult { drops, slices };
329    let pf = ctx.pending_files[0].clone();
330    ctx.merge_chunked_file(&pf, result);
331    // Patch the file_len now that we know it.
332    ctx.pending_files[0].file_len = total_len;
333    // The inode was already pushed by merge_chunked_file with the old
334    // (zero) file_len; correct it.
335    if let Some(inode) = ctx.inodes.last_mut() {
336        if let PendingContent::DropBacked { file_len, .. } = &mut inode.content {
337            *file_len = total_len;
338        }
339    }
340
341    ctx.train_and_apply_dictionary(&config.dictionaries);
342    let artifact = ctx.assemble();
343    Ok(artifact)
344}
345
346/// Pack a directory tree as a **layer** on top of a base image.
347///
348/// Produces a `.lim` image whose drops are split into two sets:
349///
350/// - **Local drops** — chunks in `root` whose `DropId` is NOT in
351///   `base_image`'s drop set. These are compressed and stored in the
352///   layer's own slabs exactly as `write_directory_with_config`
353///   would store them.
354/// - **Referenced drops** — chunks whose `DropId` IS in the base.
355///   These are recorded only as `PendingSlice` references (so the
356///   metadata tree links them in); no slab bytes are emitted in the
357///   layer. The reader resolves them via the overlay chain.
358///
359/// The resulting manifest carries a `delta_linkage` section pointing
360/// at the base image's `ManifestRoot`, so any reader that supports
361/// overlay chains can extract the layer standalone or stacked on the
362/// base.
363///
364/// # Determinism
365///
366/// `write_layer` is deterministic given the same `base_image`, the
367/// same `root` content, and the same `config`. Two runs produce
368/// byte-identical layer images.
369///
370/// # Errors
371///
372/// Returns [`WriteError::Io`] on read failure or any writer-pipeline
373/// error.
374///
375/// # Example
376///
377/// ```no_run
378/// use limnifs_write::{write_layer, profile};
379///
380/// let base = std::path::Path::new("base.lim");
381/// let root = std::path::Path::new("./new-content");
382/// let cfg = profile::balanced();
383/// let artifact = write_layer(base, root, &cfg).expect("layer");
384/// // artifact.bytes is the layer manifest; slabs contain only NEW drops.
385/// ```
386pub fn write_layer(
387    base_image: &Path,
388    root: &Path,
389    config: &WriteConfig,
390) -> Result<WriteArtifact, WriteError> {
391    // Load the base image's drop set + manifest root.
392    let base_root = load_base_drop_index(base_image)?.1;
393    let base_drop_index: std::sync::Arc<dyn BaseDropSet> = {
394        #[cfg(feature = "sparse-index")]
395        {
396            match SparseBackedBaseIndex::open(base_image) {
397                Some(idx) => std::sync::Arc::new(idx),
398                None => std::sync::Arc::new(load_base_drop_index(base_image)?.0),
399            }
400        }
401        #[cfg(not(feature = "sparse-index"))]
402        {
403            std::sync::Arc::new(load_base_drop_index(base_image)?.0)
404        }
405    };
406
407    let mut ctx = WriteContext::new();
408    ctx.chunker = chunker_from_config(config)?;
409    ctx.categorizers_disabled = config.categorizers.is_empty();
410    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
411    ctx.auto_turnover = config.turnover_threshold > 0;
412    ctx.collect_dict_samples = config.dictionaries.enabled;
413    ctx.inline_threshold = config.defaults.inline_threshold as usize;
414    ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
415    ctx.emit_shared_inline = config.defaults.shared_inline;
416    ctx.base_drop_index = Some(base_drop_index);
417    ctx.base_root = Some(base_root);
418
419    // Rest is identical to write_directory_with_config.
420    let root_inode_number = ctx.walk(root)?;
421    ctx.root_inode_number = root_inode_number;
422    write_directory_body(&mut ctx, config)?;
423    Ok(ctx.assemble())
424}
425
426/// Load every DropId present in a base image's slabs + the image's
427/// `ManifestRoot`. Used by `write_layer` to decide which chunks can
428/// be referenced rather than re-encoded.
429/// A base image's drop set, queried per chunk during layer writes.
430///
431/// The exact form is a `HashSet` built by opening every base slab.
432/// The sparse form (`SparseBackedBaseIndex`, `sparse-index` feature)
433/// keeps that cost OFF the layer build until first needed: a
434/// false-negative-free Bloom filter answers "definitely not in
435/// base" in O(1), and only a *probable* hit lazily loads the exact
436/// set. False positives are impossible to observe — the fallback
437/// is exact — so layer output is byte-identical either way.
438pub trait BaseDropSet: Send + Sync {
439    /// Exact-membership answer (may lazily load state on first
440    /// probable hit).
441    fn base_contains(&self, drop_id: &[u8; 32]) -> bool;
442}
443
444impl BaseDropSet for std::collections::HashSet<[u8; 32]> {
445    fn base_contains(&self, drop_id: &[u8; 32]) -> bool {
446        self.contains(drop_id)
447    }
448}
449
450/// Bloom-fronted base index with exact, lazily-loaded fallback
451/// (`sparse-index` feature). Reads the `<image>.sparse` sidecar
452/// emitted by [`emit_sparse_sidecar`]; the base's slabs are only
453/// opened if some chunk is a *probable* member — low-overlap layer
454/// builds never touch them.
455#[cfg(feature = "sparse-index")]
456pub struct SparseBackedBaseIndex {
457    bloom: crate::sparse_index::SparseIndexReader,
458    manifest_path: std::path::PathBuf,
459    exact: std::sync::OnceLock<std::collections::HashSet<[u8; 32]>>,
460}
461
462#[cfg(feature = "sparse-index")]
463impl SparseBackedBaseIndex {
464    /// Open `<base_image>.sparse`. `None` when the sidecar does not
465    /// exist (caller falls back to the exact set).
466    #[must_use]
467    pub fn open(base_image: &Path) -> Option<Self> {
468        let sidecar = base_image.with_extension("lim.sparse");
469        let bloom = crate::sparse_index::SparseIndexReader::from_file(&sidecar)?;
470        Some(Self {
471            bloom,
472            manifest_path: base_image.to_path_buf(),
473            exact: std::sync::OnceLock::new(),
474        })
475    }
476
477    fn load_exact(&self) -> &std::collections::HashSet<[u8; 32]> {
478        self.exact.get_or_init(|| {
479            // The base manifest tells us which slabs to open; from
480            // there it's the same enumeration `load_base_drop_index`
481            // performs.
482            let bytes = std::fs::read(&self.manifest_path).unwrap_or_default();
483            let mut cursor = ManifestCursor::new(&bytes);
484            let _ = parse_manifest_header(&mut cursor);
485            let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
486            let _ = limnifs_core::parse_metadata_reference(&mut cursor);
487            let Ok(index) = parse_slab_index(&mut cursor) else {
488                return std::collections::HashSet::new();
489            };
490            match SlabStore::load_mmap(&self.manifest_path, &index) {
491                Ok(store) => store.drop_index_keys().copied().collect(),
492                Err(_) => std::collections::HashSet::new(),
493            }
494        })
495    }
496}
497
498#[cfg(feature = "sparse-index")]
499impl BaseDropSet for SparseBackedBaseIndex {
500    fn base_contains(&self, drop_id: &[u8; 32]) -> bool {
501        // Bloom false negatives are impossible: a "no" is final and
502        // costs one bit-probe. A "yes" may be the 1% false positive,
503        // so fall through to the exact set — output stays
504        // byte-identical to the exact-only path.
505        if !self.bloom.probably_contains(drop_id) {
506            return false;
507        }
508        self.load_exact().contains(drop_id)
509    }
510}
511
512/// Emit the `<image>.sparse` Bloom sidecar for a finished artifact
513/// (`sparse-index` feature). Subsequent `write_layer` builds over
514/// this image skip opening its slabs unless a chunk is probably
515/// present.
516///
517/// # Errors
518/// Returns [`WriteError::Io`] on serialisation or write failure.
519#[cfg(feature = "sparse-index")]
520pub fn emit_sparse_sidecar(artifact: &WriteArtifact, image_path: &Path) -> Result<(), WriteError> {
521    let all: std::collections::HashSet<[u8; 32]> = artifact
522        .slabs
523        .iter()
524        .flat_map(|s| s.drop_ids.iter().copied())
525        .collect();
526    let mut writer = crate::sparse_index::SparseIndexWriter::new(
527        all.len().max(1),
528        crate::sparse_index::DEFAULT_FPP,
529    );
530    writer.insert_all(&all);
531    let sidecar = image_path.with_extension("lim.sparse");
532    writer.write_to_file(&sidecar).map_err(WriteError::Io)
533}
534
535fn load_base_drop_index(
536    base_image: &Path,
537) -> Result<(std::collections::HashSet<[u8; 32]>, [u8; 32]), WriteError> {
538    let manifest_bytes = std::fs::read(base_image)?;
539    let mut cursor = ManifestCursor::new(&manifest_bytes);
540    let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
541    // Walk sections in spec order: flags → metadata_reference → slab_index.
542    let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
543    let _ = limnifs_core::parse_metadata_reference(&mut cursor);
544    let slab_index = parse_slab_index(&mut cursor).map_err(io_core)?;
545    let store = SlabStore::load_mmap(base_image, &slab_index).map_err(io_core)?;
546    let drop_set: std::collections::HashSet<[u8; 32]> = store.drop_index_keys().copied().collect();
547    // Re-derive the Merkle root from the base manifest's section
548    // bytes. The base's `ManifestRoot` is the canonical anchor for
549    // the layer's `delta_linkage.base_root` field — round-tripping
550    // through section hashes guarantees it matches what the base
551    // reported on its own assemble path.
552    let root = *compute_merkle_root_from_sections(&manifest_bytes).as_bytes();
553    Ok((drop_set, root))
554}
555
556/// Re-derive a manifest's `ManifestRoot` from its on-disk section
557/// bytes. Mirrors `flatten::compute_merkle_root_from_sections` but
558/// tolerates absent optional sections (returns hash_empty_section()
559/// for them). Used by `load_base_drop_index` to anchor a layer's
560/// `base_root` without re-instantiating a full manifest parser.
561fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
562    use limnifs_core::SectionHashes;
563    let mut cursor = ManifestCursor::new(manifest);
564    let header_start = 0;
565    if parse_manifest_header(&mut cursor).is_err() {
566        // Not a valid manifest; fall back to all-zero root.
567        return ManifestRoot::from_bytes([0u8; 32]);
568    }
569    let header_end = cursor.position();
570    // Optional sections — best-effort parse; failures hash as empty.
571    let flags_start = header_end;
572    let flags_end = match limnifs_core::parse_feature_flags_section(&mut cursor) {
573        Ok(_) => cursor.position(),
574        Err(_) => flags_start,
575    };
576    let meta_ref_start = flags_end;
577    let metadata_reference = match limnifs_core::parse_metadata_reference(&mut cursor) {
578        Ok(m) => Some(m),
579        Err(_) => None,
580    };
581    let meta_ref_end = cursor.position();
582    let slab_index_start = meta_ref_end;
583    let _ = parse_slab_index(&mut cursor);
584    let slab_index_end = cursor.position();
585    let history_start = slab_index_end;
586    let _ = limnifs_core::parse_history(&mut cursor);
587    let history_end = cursor.position();
588
589    let hashes = SectionHashes {
590        metadata: metadata_reference
591            .map(|m| m.metadata_hash)
592            .unwrap_or_else(hash_empty_section),
593        format_header: hash_section(&manifest[header_start..header_end]),
594        feature_flags: hash_section(&manifest[flags_start..flags_end]),
595        metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
596        slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
597        crypto_params: hash_empty_section(),
598        ec_params: hash_empty_section(),
599        dms_policy: hash_empty_section(),
600        delta_linkage: hash_empty_section(),
601        history: hash_section(&manifest[history_start..history_end]),
602    };
603    compute_merkle_root(&hashes)
604}
605
606fn io_core(e: limnifs_core::CoreError) -> WriteError {
607    WriteError::Io(std::io::Error::other(format!("base image load: {e}")))
608}
609
610/// Shared body between `write_directory_with_config` and `write_layer`.
611/// Walks `ctx.pending_files` through `process_file` in parallel and
612/// merges the results back. Caller is responsible for `walk()` and
613/// `assemble()`.
614fn write_directory_body(ctx: &mut WriteContext, config: &WriteConfig) -> Result<(), WriteError> {
615    use rayon::prelude::*;
616
617    ctx.metadata_codec = config
618        .metadata_codec_id()
619        .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
620
621    ctx.chunker = chunker_from_config(config)?;
622
623    let pending = std::mem::take(&mut ctx.pending_files);
624    if pending.is_empty() {
625        return Ok(());
626    }
627    ctx.inline_threshold = config.defaults.inline_threshold as usize;
628    ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
629    ctx.emit_shared_inline = config.defaults.shared_inline;
630    let chunker = ctx.chunker.clone();
631    let classifier = ctx.classifier;
632    let text_codec = config.text_codec_id().unwrap_or(0x04);
633    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
634    let tunables = config.to_core_tunables();
635    let use_categorizers = !config.categorizers.is_empty();
636    let skip_chunking = config.skip_chunking;
637    let registry = config
638        .codec_registry()
639        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
640    let tournament_codec_ids: Vec<u8> = config
641        .tournament
642        .codecs
643        .iter()
644        .filter_map(|n| registry.lookup_by_name(n))
645        .collect();
646    let tournament_spec = TournamentSpec {
647        codec_ids: tournament_codec_ids,
648        min_size: config.tournament.min_size_threshold as usize,
649        skip_for_binary: config.tournament.skip_for_binary,
650        short_circuit_permille: config.tournament.short_circuit_threshold,
651    };
652    let base_drop_index: Option<&dyn BaseDropSet> = ctx.base_drop_index.as_deref();
653    let inline_threshold = ctx.inline_threshold;
654    let max_drop_size = config.defaults.max_drop_size as usize;
655    let seekable_drops = config.defaults.seekable_drops;
656    let seekable_drops = config.defaults.seekable_drops;
657    let results: Vec<ChunkedFileResult> = pending
658        .par_iter()
659        .map(|pf| {
660            process_file(
661                pf,
662                &chunker,
663                classifier,
664                text_codec,
665                binary_codec,
666                &tunables,
667                use_categorizers,
668                skip_chunking,
669                &tournament_spec,
670                base_drop_index,
671                inline_threshold,
672                max_drop_size,
673                seekable_drops,
674                config.categorizers.as_slice(),
675                &|name| {
676                    config
677                        .codec_registry()
678                        .ok()
679                        .and_then(|r| r.lookup_by_name(name))
680                },
681            )
682        })
683        .collect::<Result<Vec<_>, _>>()?;
684
685    for (pf, result) in pending.iter().zip(results) {
686        ctx.merge_chunked_file(pf, result);
687    }
688    ctx.train_and_apply_dictionary(&config.dictionaries);
689    Ok(())
690}
691
692/// Create an image with a custom [`WriteConfig`] (e.g. from a profile).
693pub fn write_directory_with_config(
694    root: &Path,
695    config: &WriteConfig,
696) -> Result<WriteArtifact, WriteError> {
697    let mut ctx = WriteContext::new();
698    ctx.chunker = chunker_from_config(config)?;
699    ctx.categorizers_disabled = config.categorizers.is_empty();
700    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
701    ctx.auto_turnover = config.turnover_threshold > 0;
702    ctx.collect_dict_samples = config.dictionaries.enabled;
703
704    write_directory_streaming(&mut ctx, root, config)?;
705    Ok(ctx.assemble())
706}
707
708/// Walk + compress with producer/consumer overlap (TODO.perf/15).
709///
710/// The tree walk runs on a scoped producer thread and forwards each
711/// deferred file to a bounded channel; rayon workers (via `par_bridge`)
712/// compress while the walk is still descending. For warm-cache trees
713/// with few files this is equivalent to the collect-then-dispatch
714/// shape; for huge or cold-cache trees it hides walk latency behind
715/// compression.
716///
717/// **Determinism:** results are re-sequenced into walk order before
718/// merging, and inode allocation / dir-node construction are
719/// untouched, so the emitted bytes are identical to
720/// `write_directory_body` for the same input.
721fn write_directory_streaming(
722    ctx: &mut WriteContext,
723    root: &Path,
724    config: &WriteConfig,
725) -> Result<(), WriteError> {
726    use rayon::prelude::*;
727
728    ctx.metadata_codec = config
729        .metadata_codec_id()
730        .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
731
732    ctx.chunker = chunker_from_config(config)?;
733
734    let chunker = ctx.chunker.clone();
735    let classifier = ctx.classifier;
736    let text_codec = config.text_codec_id().unwrap_or(0x04);
737    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
738    let tunables = config.to_core_tunables();
739    let use_categorizers = !config.categorizers.is_empty();
740    let skip_chunking = config.skip_chunking;
741    let registry = config
742        .codec_registry()
743        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
744    let tournament_codec_ids: Vec<u8> = config
745        .tournament
746        .codecs
747        .iter()
748        .filter_map(|n| registry.lookup_by_name(n))
749        .collect();
750    let tournament_spec = TournamentSpec {
751        codec_ids: tournament_codec_ids,
752        min_size: config.tournament.min_size_threshold as usize,
753        skip_for_binary: config.tournament.skip_for_binary,
754        short_circuit_permille: config.tournament.short_circuit_threshold,
755    };
756    // The producer thread owns `&mut ctx` for the duration of the
757    // walk, so the layer fast-path index travels as a clone.
758    let base_drop_index = ctx.base_drop_index.clone();
759    let inline_threshold = ctx.inline_threshold;
760    let max_drop_size = config.defaults.max_drop_size as usize;
761    let seekable_drops = config.defaults.seekable_drops;
762
763    ctx.inline_threshold = config.defaults.inline_threshold as usize;
764    ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
765    ctx.emit_shared_inline = config.defaults.shared_inline;
766
767    // Bounded so the walk back-pressures if compression falls behind;
768    // the buffer is large enough to keep every worker fed on bursty
769    // directory layouts.
770    const PIPELINE_CAPACITY: usize = 256;
771    let (tx, rx) = std::sync::mpsc::sync_channel::<PendingFile>(PIPELINE_CAPACITY);
772    ctx.pending_sink = Some(tx);
773
774    let (root_inode_number, mut results): (
775        u64,
776        Vec<(usize, PendingFile, Result<ChunkedFileResult, WriteError>)>,
777    ) = std::thread::scope(|scope| {
778        let producer = {
779            let ctx = &mut *ctx;
780            let root = root;
781            scope.spawn(move || {
782                let r = ctx.walk(root);
783                // Disconnect the channel so the consumer's iterator
784                // terminates; the sink stays None until the next
785                // streaming write resets it after the scope.
786                ctx.pending_sink = None;
787                r
788            })
789        };
790        // par_bridge does not preserve order; carry the arrival index
791        // and re-sequence before merging.
792        let results = rx
793            .into_iter()
794            .enumerate()
795            .par_bridge()
796            .map(|(i, pf)| {
797                let r = process_file(
798                    &pf,
799                    &chunker,
800                    classifier,
801                    text_codec,
802                    binary_codec,
803                    &tunables,
804                    use_categorizers,
805                    skip_chunking,
806                    &tournament_spec,
807                    base_drop_index.as_deref(),
808                    inline_threshold,
809                    max_drop_size,
810                    seekable_drops,
811                    config.categorizers.as_slice(),
812                    &|name| {
813                        config
814                            .codec_registry()
815                            .ok()
816                            .and_then(|r| r.lookup_by_name(name))
817                    },
818                );
819                (i, pf, r)
820            })
821            .collect();
822        let joined = producer
823            .join()
824            .unwrap_or_else(|_| {
825                Err(WriteError::Io(std::io::Error::other(
826                    "walk thread panicked",
827                )))
828            })
829            .map(|n| (n, results));
830        // Scope can't `?` across borrows of `results`; return the
831        // outcome and propagate outside.
832        joined
833    })?;
834    ctx.pending_sink = None;
835    ctx.root_inode_number = root_inode_number;
836
837    results.sort_unstable_by_key(|(i, _, _)| *i);
838    // Fail on the lowest walk index first, matching the
839    // collect::<Result<Vec<_>, _>> abort semantics of the
840    // collect-then-dispatch shape.
841    for (_, pf, r) in results {
842        ctx.merge_chunked_file(&pf, r?);
843    }
844    ctx.train_and_apply_dictionary(&config.dictionaries);
845    Ok(())
846}
847
848/// One chunk of a file before dedup: (`drop_id`, `plaintext`, `compressed`, `codec`).
849///
850/// `compressed` is `Arc<[u8]>` so the cross-file compress cache can
851/// share bytes across hits with a refcount bump instead of a deep
852/// copy — dedup-heavy workloads (container layers, duplicate files)
853/// skip the allocation entirely.
854pub(crate) type RawDrop = ([u8; 32], Vec<u8>, std::sync::Arc<[u8]>, u8, u8);
855/// Result of parallel file processing: the drop data (uncompressed,
856pub(crate) struct ChunkedFileResult {
857    drops: Vec<RawDrop>, // (id, plaintext, compressed, codec, flags)
858    slices: Vec<PendingSlice>,
859}
860
861/// Resolved tournament configuration passed to per-chunk compression.
862///
863/// Built once at the top level from `WriteConfig::tournament` so each
864/// rayon worker reuses the same Vec rather than rebuilding it per
865/// file. Codecs are stored as numeric ids (looked up via
866/// `WriteConfig::codec_registry`) — `process_file` never sees the
867/// string form.
868struct TournamentSpec {
869    /// Codec ids to try, in declared order. `process_file` iterates
870    /// these and tracks the best compression. `CODEC_STORE` entries
871    /// are ignored (store is always the implicit fallback).
872    codec_ids: Vec<u8>,
873    /// Chunks below this many bytes get the preferred codec only
874    /// (no tournament) — the per-codec setup cost dominates at small
875    /// sizes and the ratio difference is negligible.
876    min_size: usize,
877    /// When true, binary-classified chunks skip the tournament and
878    /// use `binary_codec` directly. Matches the v0.1 behaviour where
879    /// binary chunks were never worth the tournament cost.
880    skip_for_binary: bool,
881    /// Short-circuit threshold in per-mille (0..=1000). 0 disables
882    /// short-circuit. Whenever a codec achieves compression ratio
883    /// ≤ threshold, the tournament accepts it and skips any slower
884    /// codecs later in the list.
885    short_circuit_permille: u32,
886}
887
888/// Compress a whole file as a single drop using the categorizer's
889/// chosen codec. Used when a file-level categorizer claims the file
890/// (FLAC for WAV, ricepp for FITS, FSST+Brotli for CSV). The drop's
891/// slice covers the whole file; no `FastCDC` chunking happens.
892///
893/// Codec parameters extracted by the categorizer (e.g. PCM sample
894/// format, FITS bitpix) are NOT prepended to the compressed bytes —
895/// the codec embeds its own params in its container format. The
896/// `LimniFS` drop record just stores `(codec_id, compressed_bytes)`
897/// and lets the codec own its param encoding. The categorizer's
898/// `codec_params` field is reserved for future use when a codec
899/// needs params NOT embedded in its container.
900/// Build the chunker from the config's `[chunking]` section. The
901/// section was previously parsed and validated but never applied —
902/// `WriteContext` hardcoded `FastCDC::default()`. Defaults in
903/// `default_v0_1` match the previous effective values, so default
904/// images are byte-identical; only configs that set `[chunking]`
905/// change output (which is the point of setting it).
906fn chunker_from_config(config: &WriteConfig) -> Result<FastCDC, WriteError> {
907    FastCDC::new(
908        config.chunking.min_chunk_size as usize,
909        config.chunking.avg_chunk_size as usize,
910        config.chunking.max_chunk_size as usize,
911    )
912    .map_err(|e| WriteError::Io(std::io::Error::other(format!("chunking config: {e}"))))
913}
914
915/// Encode `plaintext` as a seekable container when the drop is large
916/// enough to hurt cold random reads and the codec supports
917/// independent frames (TODO.sota-fs/05). Ratio cost is bounded by
918/// per-frame independence; the reader gains 256 KiB-bounded windowed
919/// decode. Encoder failure degrades to the monolithic stream — a
920/// container problem must never fail the write.
921pub(crate) fn seekable_or_monolithic(
922    codec: u8,
923    plaintext: &[u8],
924    compressed: std::sync::Arc<[u8]>,
925    tunables: &limnifs_core::codec::CodecTunables,
926    seekable_drops: bool,
927    threshold: usize,
928) -> (std::sync::Arc<[u8]>, u8) {
929    use limnifs_core::seekable::{
930        encode_seekable, is_seekable_codec, DROP_FLAG_SEEKABLE as FLAG, SEEKABLE_EMISSION_THRESHOLD,
931    };
932    if seekable_drops && plaintext.len() > threshold && is_seekable_codec(codec) {
933        if let Ok(container) = encode_seekable(codec, plaintext, tunables) {
934            return (container.into(), FLAG);
935        }
936    }
937    (compressed, 0)
938}
939
940/// Per-chunk emission threshold. FastCDC chunks are bounded by
941/// `max_chunk_size` (default 1 MiB) so the whole-file 1 MiB
942/// threshold can never fire per chunk — limnifs#195. Chunk drops as
943/// small as one frame (256 KiB) still gain the covering-frames
944/// decode bound: an 8 KiB window inside a 256 KiB drop decodes one
945/// 8 KiB-ish frame instead of the whole drop.
946pub(crate) const SEEKABLE_CHUNK_EMISSION_THRESHOLD: usize =
947    limnifs_core::seekable::SEEKABLE_FRAME_SIZE;
948
949fn process_whole_file_drop(
950    pf: &PendingFile,
951    data: &[u8],
952    cat: file_categorizer::Categorization,
953    tunables: &limnifs_core::codec::CodecTunables,
954    seekable_drops: bool,
955) -> Result<ChunkedFileResult, WriteError> {
956    let _ = pf;
957    let drop_id = hash_section(data);
958    let file_len = u64::try_from(data.len()).unwrap_or(u64::MAX);
959
960    // Brotli first; if it fails (including a codec panic — the
961    // registry converts panics to Err), fall back to ZSTD, then to
962    // STORE. A broken encoder must degrade the drop's ratio, never
963    // the write itself.
964    let (mut best_codec, mut best_compressed): (u8, std::sync::Arc<[u8]>) =
965        match limnifs_core::codec::compress_with_tunables(
966            limnifs_core::codec::CODEC_BROTLI,
967            data,
968            tunables,
969        ) {
970            Ok(c) => (limnifs_core::codec::CODEC_BROTLI, c.into()),
971            Err(_) => match limnifs_core::codec::compress_with_tunables(
972                limnifs_core::codec::CODEC_ZSTD,
973                data,
974                tunables,
975            ) {
976                Ok(c) => (limnifs_core::codec::CODEC_ZSTD, c.into()),
977                Err(_) => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
978            },
979        };
980
981    // Short-circuit: if Brotli already achieves < 5% ratio, the input
982    // is highly compressible and ZSTD is unlikely to beat it by enough
983    // to justify the extra pass. Skip ZSTD on this fast path.
984    let brotli_ratio = best_compressed.len() as f64 / data.len() as f64;
985    if brotli_ratio > 0.05 {
986        if let Ok(zstd_c) = limnifs_core::codec::compress_with_tunables(
987            limnifs_core::codec::CODEC_ZSTD,
988            data,
989            tunables,
990        ) {
991            if zstd_c.len() < best_compressed.len() {
992                best_codec = limnifs_core::codec::CODEC_ZSTD;
993                best_compressed = zstd_c.into();
994            }
995        }
996    }
997
998    // Only try the specialized codec if the general-purpose ratio
999    // is poor (>15%) — otherwise the specialized codec is unlikely
1000    // to help and may be very slow (FLAC, FSST). RICEPP is always
1001    // tried because it can win big on FITS even when general-purpose
1002    // ratios look acceptable.
1003    let general_ratio = best_compressed.len() as f64 / data.len() as f64;
1004    if general_ratio > 0.15 || cat.codec_id == limnifs_core::codec::CODEC_RICEPP {
1005        // For FSST+Brotli, pass the already-computed Brotli baseline so
1006        // the codec doesn't re-compress the plaintext with Brotli just
1007        // for the comparison check.
1008        let spec_result = if cat.codec_id == limnifs_core::codec::CODEC_FSST_BROTLI {
1009            limnifs_core::codec::fsst_brotli::compress_with_baseline(data, Some(&best_compressed))
1010        } else {
1011            // Tunables-routed so future codec knobs (FLAC/RicePP have
1012            // none today — all parameters come from the file header)
1013            // are honored here automatically (TODO.remaining item 2).
1014            limnifs_core::codec::compress_with_tunables(cat.codec_id, data, tunables)
1015        };
1016        if let Ok(spec_c) = spec_result {
1017            if spec_c.len() < best_compressed.len() {
1018                best_codec = cat.codec_id;
1019                best_compressed = spec_c.into();
1020            }
1021        }
1022    }
1023
1024    let (best_compressed, flags) = seekable_or_monolithic(
1025        best_codec,
1026        data,
1027        best_compressed,
1028        tunables,
1029        seekable_drops,
1030        limnifs_core::seekable::SEEKABLE_EMISSION_THRESHOLD,
1031    );
1032    Ok(ChunkedFileResult {
1033        drops: vec![(drop_id, data.to_vec(), best_compressed, best_codec, flags)],
1034        slices: vec![PendingSlice {
1035            drop_id,
1036            file_byte_start: 0,
1037            file_byte_end: file_len,
1038        }],
1039    })
1040}
1041
1042/// Process a single file's contents (CPU-heavy work that runs in a
1043/// rayon worker thread). Returns the unique chunks and slice map.
1044///
1045/// First consults the file-level categorizer registry. If a
1046/// categorizer claims the file (e.g. FLAC for WAV, ricepp for FITS,
1047/// Compress a single chunk via the configured tournament.
1048///
1049/// Iterates `tournament.codec_ids` in declared order, tracks the
1050/// smallest output, and short-circuits when a codec achieves
1051/// compression ratio ≤ `tournament.short_circuit_permille`.
1052///
1053/// Special cases:
1054/// - **Binary chunks with `skip_for_binary`**: skip the tournament
1055///   entirely and use `binary_codec`. Matches v0.1 behaviour.
1056/// - **Chunks smaller than `min_size`**: use the class's preferred
1057///   codec directly. Per-codec setup cost dominates here and the
1058///   ratio difference is negligible at small sizes.
1059/// - **Class unknown to writer** (Unknown / future classes): STORE.
1060///
1061/// The tournament never tries `CODEC_STORE` (id 0x00) — store is
1062/// always the implicit fallback if every codec fails to compress.
1063fn compress_chunk_with_tournament(
1064    chunk: &[u8],
1065    class: classifier::Class,
1066    text_codec: u8,
1067    binary_codec: u8,
1068    tunables: &limnifs_core::codec::CodecTunables,
1069    tournament: &TournamentSpec,
1070) -> (u8, std::sync::Arc<[u8]>) {
1071    use classifier::Class;
1072
1073    let preferred = match class {
1074        Class::Binary => binary_codec,
1075        Class::Text | Class::Code | Class::Sparse => text_codec,
1076        _ => limnifs_core::codec::CODEC_STORE,
1077    };
1078
1079    if preferred == limnifs_core::codec::CODEC_STORE {
1080        return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
1081    }
1082    if class == Class::Binary && tournament.skip_for_binary {
1083        return compress_chunk_one(chunk, preferred, tunables);
1084    }
1085    if chunk.len() < tournament.min_size {
1086        return compress_chunk_one(chunk, preferred, tunables);
1087    }
1088
1089    let mut best: Option<(u8, std::sync::Arc<[u8]>)> = None;
1090    for &codec_id in &tournament.codec_ids {
1091        if codec_id == limnifs_core::codec::CODEC_STORE {
1092            continue;
1093        }
1094        let c = match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
1095            Ok(c) => c,
1096            Err(_) => continue,
1097        };
1098        if c.len() >= chunk.len() {
1099            continue;
1100        }
1101        let ratio_permille = (c.len() as u64 * 1000 / chunk.len() as u64) as u32;
1102        let is_best_so_far = best.as_ref().map_or(true, |(_, b)| c.len() < b.len());
1103        if is_best_so_far {
1104            best = Some((codec_id, c.into()));
1105        }
1106        if tournament.short_circuit_permille > 0
1107            && ratio_permille <= tournament.short_circuit_permille
1108        {
1109            break;
1110        }
1111    }
1112
1113    best.unwrap_or_else(|| (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()))
1114}
1115
1116/// Compress `chunk` with a single codec, falling back to STORE if
1117/// the codec fails or expansion occurs.
1118fn compress_chunk_one(
1119    chunk: &[u8],
1120    codec_id: u8,
1121    tunables: &limnifs_core::codec::CodecTunables,
1122) -> (u8, std::sync::Arc<[u8]>) {
1123    if codec_id == limnifs_core::codec::CODEC_STORE {
1124        return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
1125    }
1126    match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
1127        Ok(c) if c.len() < chunk.len() => (codec_id, c.into()),
1128        _ => (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()),
1129    }
1130}
1131
1132/// FSST+Brotli for CSV), the whole file is compressed as a single
1133/// drop with the categorizer's chosen codec + parameters. Otherwise
1134/// falls through to `FastCDC` + per-chunk classify.
1135fn process_file(
1136    pf: &PendingFile,
1137    chunker: &FastCDC,
1138    classifier: classifier::Classifier,
1139    text_codec: u8,
1140    binary_codec: u8,
1141    tunables: &limnifs_core::codec::CodecTunables,
1142    use_categorizers: bool,
1143    skip_chunking: bool,
1144    tournament: &TournamentSpec,
1145    base_drop_index: Option<&dyn BaseDropSet>,
1146    inline_threshold: usize,
1147    max_drop_size: usize,
1148    seekable_drops: bool,
1149    categorizer_config: &[crate::config::CategorizerConfig],
1150    codec_name_resolver: &dyn Fn(&str) -> Option<u8>,
1151) -> Result<ChunkedFileResult, WriteError> {
1152    // For files above MMAP_READ_THRESHOLD, map them rather than reading
1153    // into a Vec via std::fs::read. Pages load on demand from the
1154    // kernel page cache. The chunk-path compressors see borrowed slices
1155    // pointing into the mmap, so peak RSS stays at unique_chunks ×
1156    // avg_chunk_size rather than the full file.
1157    //
1158    // SAFETY: LimniFS packs source trees that are immutable for the
1159    // duration of the write. The file is opened read-only. External
1160    // mutation during compression would be a serious bug in the
1161    // caller's workflow (and would also break BLAKE3 determinism).
1162    let file_len_estimate = std::fs::metadata(&pf.path)
1163        .map(|m| m.len() as usize)
1164        .unwrap_or(0);
1165    // Borrow the mmap instead of materializing it: the previous
1166    // `Vec::from(&mmap[..])` copied every byte of every file ≥ 1 MiB,
1167    // defeating the mapping entirely (one full memcpy plus peak RSS
1168    // equal to `fs::read`). Chunking, hashing, and compression all
1169    // work on borrowed slices; the only owned copies are the per-drop
1170    // plaintexts the context retains.
1171    let mmap_handle: memmap2::Mmap;
1172    let small: Vec<u8>;
1173    let data: &[u8] = if file_len_estimate >= MMAP_READ_THRESHOLD {
1174        let file = std::fs::File::open(&pf.path)?;
1175        // SAFETY: LimniFS packs source trees that are immutable for
1176        // the duration of the write; the file is opened read-only and
1177        // the mapping is only read.
1178        #[allow(unsafe_code)]
1179        let mapped = unsafe { memmap2::Mmap::map(&file) }.map_err(WriteError::Io)?;
1180        mmap_handle = mapped;
1181        &mmap_handle[..]
1182    } else {
1183        small = std::fs::read(&pf.path)?;
1184        &small[..]
1185    };
1186    let file_len = data.len();
1187
1188    // Skip FastCDC chunking entirely; compress the whole file as
1189    // one drop. Trades dedup granularity for create speed. Used by
1190    // the max-write profile where speed >> ratio. The per-file LZ4
1191    // compress at ~1 GB/s is faster than FastCDC hashing overhead
1192    // for all but the largest multi-GB files (where rayon parallelism
1193    // across chunks would help).
1194    if skip_chunking && file_len > inline_threshold {
1195        let drop_id = hash_section(&data);
1196        let class = classifier.classify(&data);
1197        let preferred_codec = match class {
1198            classifier::Class::Binary => binary_codec,
1199            _ => text_codec,
1200        };
1201        let (codec_id, compressed): (u8, std::sync::Arc<[u8]>) =
1202            match limnifs_core::codec::compress_with_tunables(preferred_codec, &data, tunables) {
1203                Ok(c) if c.len() < data.len() => (preferred_codec, c.into()),
1204                _ => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
1205            };
1206        let (compressed, flags) = seekable_or_monolithic(
1207            codec_id,
1208            &data,
1209            compressed,
1210            tunables,
1211            seekable_drops,
1212            SEEKABLE_CHUNK_EMISSION_THRESHOLD,
1213        );
1214        return Ok(ChunkedFileResult {
1215            drops: vec![(drop_id, data.to_vec(), compressed, codec_id, flags)],
1216            slices: vec![PendingSlice {
1217                drop_id,
1218                file_byte_start: 0,
1219                file_byte_end: file_len as u64,
1220            }],
1221        });
1222    }
1223
1224    if use_categorizers {
1225        // Config entries FIRST (limnifs#196): the user's
1226        // `[[categorizers]]` rules override the built-ins.
1227        let config_cat = file_categorizer::ConfigCategorizer::new(categorizer_config.to_vec());
1228        if let Some(cat) = config_cat.categorize(&pf.path, &data) {
1229            if let Some(codec_id) = file_categorizer::resolve_config_categorization(
1230                &cat,
1231                categorizer_config,
1232                codec_name_resolver,
1233            ) {
1234                let within_cap = max_drop_size == 0 || file_len <= max_drop_size;
1235                let needs_whole_file = matches!(
1236                    codec_id,
1237                    limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
1238                );
1239                if within_cap && (needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE) {
1240                    let mut cat = cat;
1241                    cat.codec_id = codec_id;
1242                    return process_whole_file_drop(pf, &data, cat, tunables, seekable_drops);
1243                }
1244            }
1245        }
1246        if let Some(cat) = file_categorizer::default_registry().categorize(&pf.path, &data) {
1247            let needs_whole_file = matches!(
1248                cat.codec_id,
1249                limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
1250            );
1251            // max_drop_size bounds the decompressed unit: files over
1252            // the cap fall through to chunking + tournament (0 = off).
1253            let within_cap = max_drop_size == 0 || file_len <= max_drop_size;
1254            if within_cap && (needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE) {
1255                return process_whole_file_drop(pf, &data, cat, tunables, seekable_drops);
1256            }
1257        }
1258    }
1259
1260    let chunks = chunker.chunk_slice(&data);
1261
1262    // Phase 1: hash all chunks in parallel, then build slices + filter
1263    // duplicates sequentially. Each chunk's BLAKE3 is independent, so
1264    // the map parallelizes across rayon workers (work stealing — Phase
1265    // 2 nests par_iter inside a worker the same way). This closes the
1266    // sequential hashing tail that left N-1 cores idle on
1267    // single-large-file workloads. Drop ids are per-chunk values and
1268    // the collect preserves chunk order, so output is byte-identical
1269    // to the sequential loop.
1270    use rayon::prelude::*;
1271    let drop_ids: Vec<[u8; 32]> = chunks.par_iter().map(|chunk| hash_section(chunk)).collect();
1272
1273    let mut slices = Vec::with_capacity(chunks.len());
1274    let mut file_offset: u64 = 0;
1275    let mut seen_in_file: std::collections::HashSet<[u8; 32]> =
1276        std::collections::HashSet::with_capacity(chunks.len());
1277    let mut unique_chunks: Vec<(&[u8], [u8; 32])> = Vec::with_capacity(chunks.len());
1278    for (chunk, drop_id) in chunks.iter().zip(drop_ids) {
1279        let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
1280        slices.push(PendingSlice {
1281            drop_id,
1282            file_byte_start: file_offset,
1283            file_byte_end: file_offset + chunk_len,
1284        });
1285        file_offset += chunk_len;
1286        if seen_in_file.insert(drop_id) {
1287            unique_chunks.push((chunk, drop_id));
1288        }
1289    }
1290
1291    // Phase 2: compress unique chunks in parallel across rayon workers.
1292    // This is the CPU-intensive step — parallelizing it gives N-core
1293    // speedup for large files with many chunks.
1294    //
1295    // Cross-file dedup: each rayon worker thread carries a thread-local
1296    // compress cache mapping DropId -> (codec_id, compressed_bytes).
1297    // When two files share a chunk (common in source trees, container
1298    // layers, tiny-files benchmarks), the second file hits the cache
1299    // and skips the compress pass entirely. Cache is bounded by entry
1300    // count; eviction is "stop inserting once full" — simple and
1301    // correct, misses are bounded by worker count.
1302    thread_local! {
1303        static COMPRESS_CACHE: std::cell::RefCell<std::collections::HashMap<[u8; 32], (u8, std::sync::Arc<[u8]>)>> =
1304            std::cell::RefCell::new(std::collections::HashMap::new());
1305    }
1306    const COMPRESS_CACHE_MAX_ENTRIES: usize = 100_000;
1307    let drops: Vec<RawDrop> = unique_chunks
1308        .par_iter()
1309        .map(|(chunk, drop_id)| {
1310            // Layer fast-path: chunk already exists in the base image
1311            // → skip compress entirely.
1312            if let Some(base) = base_drop_index {
1313                if base.base_contains(drop_id) {
1314                    return (*drop_id, Vec::new(), Vec::new().into(), CODEC_REFERENCED, 0);
1315                }
1316            }
1317            let class = classifier.classify(chunk);
1318            // Cache fast-path: identical chunk already compressed on
1319            // this worker → reuse bytes, skip tournament entirely.
1320            let cached = COMPRESS_CACHE.with(|c| {
1321                c.borrow()
1322                    .get(drop_id)
1323                    .map(|(cid, comp)| (*cid, comp.clone()))
1324            });
1325            let (codec_id, compressed) = if let Some(c) = cached {
1326                c
1327            } else {
1328                let new = compress_chunk_with_tournament(
1329                    chunk,
1330                    class,
1331                    text_codec,
1332                    binary_codec,
1333                    tunables,
1334                    tournament,
1335                );
1336                // Insert into the per-worker cache if there's room.
1337                COMPRESS_CACHE.with(|c| {
1338                    let mut cache = c.borrow_mut();
1339                    if cache.len() < COMPRESS_CACHE_MAX_ENTRIES {
1340                        // `new.1.clone()` here is an Arc refcount bump.
1341                        cache.insert(*drop_id, new.clone());
1342                    }
1343                });
1344                new
1345            };
1346            // limnifs#195: chunk drops go through the same
1347            // seekable-container emission as whole-file drops, with a
1348            // chunk-appropriate threshold (chunks are bounded by
1349            // max_chunk_size, so the whole-file 1 MiB gate can never
1350            // fire here).
1351            let (compressed, flags) = seekable_or_monolithic(
1352                codec_id,
1353                chunk,
1354                compressed,
1355                tunables,
1356                seekable_drops,
1357                SEEKABLE_CHUNK_EMISSION_THRESHOLD,
1358            );
1359            (*drop_id, chunk.to_vec(), compressed, codec_id, flags)
1360        })
1361        .collect();
1362
1363    let _ = file_len;
1364    Ok(ChunkedFileResult { drops, slices })
1365}
1366
1367struct PendingDrop {
1368    id: [u8; 32],
1369    /// Original (decompressed) byte length. Stored as a u32 rather
1370    /// than keeping the plaintext Vec around, because the writer only
1371    /// needs the length when emitting the slab's drop record. Holding
1372    /// the full plaintext until slab assembly wastes memory
1373    /// proportional to image size on top of the compressed bytes.
1374    plaintext_len: u32,
1375    compressed: std::sync::Arc<[u8]>,
1376    codec: u8,
1377    /// Dictionary id (0xFF = NO_DICT). Populated during the dict
1378    /// re-compression pass for drops that were re-compressed with a
1379    /// trained dictionary.
1380    dict_id: u8,
1381    /// Retained plaintext, present only when `collect_dict_samples`
1382    /// is true (i.e. `WriteConfig::dictionaries.enabled`). Used by
1383    /// the post-parallel dict re-compression pass. Cleared after
1384    /// re-compression to free memory before slab assembly.
1385    plaintext: Option<Vec<u8>>,
1386    /// Record flags. Bit0 = SEEKABLE (window bytes are a
1387    /// `limnifs_core::seekable` container); 0 = plain monolithic
1388    /// codec stream.
1389    flags: u8,
1390}
1391
1392impl PendingDrop {
1393    /// The byte length stored in the slab's solid window. Equals
1394    /// `plaintext_len` for store codec, or the compressed size for
1395    /// LZ4 / Brotli / etc.
1396    fn len_in_window(&self) -> u32 {
1397        u32::try_from(self.compressed.len()).expect("compressed fits u32")
1398    }
1399
1400    /// The original (decompressed) byte length.
1401    fn plaintext_len_value(&self) -> u32 {
1402        self.plaintext_len
1403    }
1404
1405    /// Contribution to the slab's total byte length: 48 bytes of drop
1406    /// record (per spec §3.3) + the compressed payload.
1407    fn slab_footprint(&self) -> usize {
1408        48 + self.compressed.len()
1409    }
1410}
1411
1412/// One slice of a file backed by drops. Records which drop holds
1413/// this slice's bytes and which byte range of the original file
1414/// the slice covers. The slice always spans the entire drop (the
1415/// chunker never splits a drop across multiple slices).
1416struct PendingSlice {
1417    drop_id: [u8; 32],
1418    file_byte_start: u64,
1419    file_byte_end: u64,
1420}
1421
1422/// A file that needs chunking (> `INLINE_THRESHOLD`). Collected during
1423/// the sequential tree walk and processed in parallel by `rayon`.
1424#[derive(Clone)]
1425struct PendingFile {
1426    inode_number: u64,
1427    path: PathBuf,
1428    mtime_ns: u64,
1429    file_len: u64,
1430}
1431
1432struct PendingInode {
1433    number: u64,
1434    mode: u32,
1435    mtime_ns: u64,
1436    content: PendingContent,
1437}
1438
1439enum PendingContent {
1440    Inline(Vec<u8>),
1441    /// Symlink target (raw, as read from the filesystem).
1442    Symlink(String),
1443    DropBacked {
1444        file_len: u64,
1445        slices: Vec<PendingSlice>,
1446    },
1447    Directory(Vec<(String, u64, u8)>),
1448}
1449
1450struct DirNode {
1451    entries: Vec<(String, u64, u8)>,
1452    bytes: Vec<u8>,
1453    hash: [u8; 32],
1454}
1455
1456struct WriteContext {
1457    next_inode: u64,
1458    inodes: Vec<PendingInode>,
1459    dir_nodes: Vec<DirNode>,
1460    drops: Vec<PendingDrop>,
1461    drop_index: HashSet<[u8; 32]>,
1462    pending_files: Vec<PendingFile>,
1463    file_count: usize,
1464    dir_count: usize,
1465    root_inode_number: u64,
1466    chunker: FastCDC,
1467    classifier: classifier::Classifier,
1468    shared_inline_map: HashMap<[u8; 32], usize>,
1469    shared_inline_table: Vec<Vec<u8>>,
1470    /// Profile name for ProfileDescriptor emission (None = omit section).
1471    profile_name: Option<String>,
1472    /// Metadata blob codec (defaults to Brotli; can be overridden via
1473    /// `WriteConfig::defaults::metadata_codec`). Used by `assemble`.
1474    metadata_codec: u8,
1475    /// Whether categorizers were disabled by the profile.
1476    categorizers_disabled: bool,
1477    /// Whether this is a RW image.
1478    rw_mode: bool,
1479    /// Whether auto-turnover is enabled.
1480    auto_turnover: bool,
1481    /// Whether to collect plaintext samples for ZSTD dictionary
1482    /// training. Set when `WriteConfig::dictionaries.enabled`.
1483    collect_dict_samples: bool,
1484    /// Plaintext samples collected from ZSTD-compressed drops, for
1485    /// training one dictionary after the parallel compress phase.
1486    /// Capped at `MAX_DICT_SAMPLES` to bound memory. Keyed by
1487    /// classifier class — text/code/sparse share a "text" dict,
1488    /// binary gets its own. Compressed/media/incompressible classes
1489    /// don't use ZSTD so their samples aren't collected.
1490    dict_samples_by_class: HashMap<crate::classifier::Class, Vec<Vec<u8>>>,
1491    /// Trained dictionaries keyed by class. Populated by
1492    /// `train_and_apply_dictionary` after the parallel phase. Emitted
1493    /// in the manifest's `dictionary_section` with one entry per
1494    /// class that accumulated enough samples.
1495    trained_dicts_by_class: HashMap<crate::classifier::Class, crate::dictionary::TrainedDictionary>,
1496    /// Drop IDs known to exist in a base image (set when this write
1497    /// is producing a layer via `write_layer`). When a chunk's DropId
1498    /// is in this set, the writer skips compression and emits no slab
1499    /// bytes — the drop is resolved via the overlay chain at read
1500    /// time. `None` for standalone (non-layer) writes.
1501    base_drop_index: Option<std::sync::Arc<dyn BaseDropSet>>,
1502    /// The base image's `ManifestRoot` (set when `base_drop_index`
1503    /// is `Some`). Emitted in the manifest's `delta_linkage` section
1504    /// so readers know which image provides the referenced drops.
1505    /// `None` for standalone writes.
1506    base_root: Option<[u8; 32]>,
1507    /// Compressed-metadata size above which the blob is externalized
1508    /// to a sidecar (issue #187). Defaults to
1509    /// [`METADATA_EXTERNALIZE_THRESHOLD`]; overridable via
1510    /// `WriteConfig::defaults::metadata_externalize_threshold`.
1511    metadata_externalize_threshold: usize,
1512    /// Whether to dedup identical inline file contents into the
1513    /// shared-inline table (issue #189). `true` (default) keeps the
1514    /// historical behavior; `false` emits plain `INLINE_DATA` inodes
1515    /// so images stay readable by pre-#186 readers whose reserved
1516    /// mask rejects the `SHARED_INLINE` flag.
1517    emit_shared_inline: bool,
1518    /// Inline-data cutoff from `WriteConfig::defaults.inline_threshold`.
1519    /// Files at or below this size are stored inline in the metadata
1520    /// blob instead of being chunked into slabs. Set from the profile
1521    /// before `walk`; defaults to the historical constant.
1522    inline_threshold: usize,
1523    /// Streaming-walk sink (TODO.perf/15). When set, `walk` forwards
1524    /// deferred files to the channel instead of buffering them in
1525    /// `pending_files`, so compression starts while the walk is still
1526    /// descending the tree. `None` keeps the collect-then-dispatch
1527    /// shape (used by the non-streaming entry points).
1528    pending_sink: Option<std::sync::mpsc::SyncSender<PendingFile>>,
1529}
1530
1531impl WriteContext {
1532    /// Cap on collected plaintext samples. Enough signal for the
1533    /// FrequencyTrainer without unbounded memory growth on huge inputs.
1534    const MAX_DICT_SAMPLES: usize = 1000;
1535
1536    fn new() -> Self {
1537        Self {
1538            next_inode: 1,
1539            inodes: Vec::new(),
1540            dir_nodes: Vec::new(),
1541            drops: Vec::new(),
1542            drop_index: HashSet::new(),
1543            pending_files: Vec::new(),
1544            file_count: 0,
1545            dir_count: 0,
1546            root_inode_number: 0,
1547            chunker: FastCDC::default(),
1548            classifier: classifier::Classifier,
1549            shared_inline_map: HashMap::new(),
1550            shared_inline_table: Vec::new(),
1551            profile_name: None,
1552            metadata_codec: limnifs_core::codec::CODEC_BROTLI,
1553            categorizers_disabled: false,
1554            rw_mode: false,
1555            auto_turnover: false,
1556            collect_dict_samples: false,
1557            dict_samples_by_class: HashMap::new(),
1558            trained_dicts_by_class: HashMap::new(),
1559            base_drop_index: None,
1560            base_root: None,
1561            pending_sink: None,
1562            inline_threshold: INLINE_THRESHOLD,
1563            metadata_externalize_threshold: METADATA_EXTERNALIZE_THRESHOLD,
1564            emit_shared_inline: true,
1565        }
1566    }
1567
1568    fn alloc_inode(&mut self) -> u64 {
1569        let n = self.next_inode;
1570        self.next_inode += 1;
1571        n
1572    }
1573
1574    /// Scan all inline-data inodes and build a dedup table. Only
1575    /// content appearing in > 1 inode is deduplicated; unique inline
1576    /// data stays inline (no overhead change).
1577    fn build_shared_inline_table(&mut self) {
1578        let mut counts: HashMap<[u8; 32], usize> = HashMap::new();
1579        for inode in &self.inodes {
1580            if let PendingContent::Inline(data) = &inode.content {
1581                let h = hash_section(data);
1582                *counts.entry(h).or_default() += 1;
1583            }
1584        }
1585        // Only dedup content that appears more than once.
1586        for inode in &self.inodes {
1587            if let PendingContent::Inline(data) = &inode.content {
1588                let h = hash_section(data);
1589                if counts.get(&h).copied().unwrap_or(0) > 1
1590                    && !self.shared_inline_map.contains_key(&h)
1591                {
1592                    let idx = self.shared_inline_table.len();
1593                    self.shared_inline_table.push(data.clone());
1594                    self.shared_inline_map.insert(h, idx);
1595                }
1596            }
1597        }
1598    }
1599
1600    /// Merge a parallel-processed chunked file's results into the
1601    /// context. Dedup: only new `DropId`s get added to the drops list.
1602    fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
1603        for (drop_id, plaintext, compressed, codec, flags) in result.drops {
1604            if self.drop_index.insert(drop_id) {
1605                // When dictionary training is enabled, classify the
1606                // plaintext and retain it for per-class dictionary
1607                // training. Text-like classes share a "text" dict;
1608                // Binary gets its own. Compressed/media/incompressible
1609                // don't use ZSTD so we skip them entirely.
1610                let retain_plaintext =
1611                    self.collect_dict_samples && codec == limnifs_core::codec::CODEC_ZSTD;
1612                if retain_plaintext {
1613                    let total: usize = self.dict_samples_by_class.values().map(Vec::len).sum();
1614                    if total < Self::MAX_DICT_SAMPLES {
1615                        let class = self.classifier.classify(&plaintext);
1616                        self.dict_samples_by_class
1617                            .entry(class)
1618                            .or_default()
1619                            .push(plaintext.clone());
1620                    }
1621                }
1622                self.drops.push(PendingDrop {
1623                    id: drop_id,
1624                    plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1625                    compressed,
1626                    codec,
1627                    dict_id: limnifs_core::drop_record::NO_DICT,
1628                    plaintext: if retain_plaintext {
1629                        Some(plaintext)
1630                    } else {
1631                        None
1632                    },
1633                    flags,
1634                });
1635            }
1636        }
1637        self.inodes.push(PendingInode {
1638            number: pf.inode_number,
1639            mode: 0o100_644,
1640            mtime_ns: pf.mtime_ns,
1641            content: PendingContent::DropBacked {
1642                file_len: pf.file_len,
1643                slices: result.slices,
1644            },
1645        });
1646    }
1647
1648    /// Apply the seine classifier to a chunk and compress it if the
1649    /// class is compressible. Text, Code, and Binary drops get LZ4;
1650    /// Compressed, Media, and Sparse drops stay as store (re-compressing
1651    /// already-compressed data wastes CPU for no gain).
1652    /// Apply the seine classifier to a chunk and compress it if the
1653    /// class is compressible. Text, Code, and Binary drops get LZ4;
1654    /// Compressed, Media, and Sparse drops stay as store.
1655    ///
1656    /// Kept for API compatibility; the parallel writer uses
1657    /// [`process_file`] which inlines this logic.
1658    #[allow(dead_code)]
1659    fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
1660        let class = self.classifier.classify(plaintext);
1661        let (codec, compressed): (u8, std::sync::Arc<[u8]>) = match class {
1662            classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
1663                let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
1664                (limnifs_core::codec::CODEC_LZ4, c.into())
1665            }
1666            _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec().into()),
1667        };
1668        PendingDrop {
1669            id: drop_id,
1670            plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1671            compressed,
1672            codec,
1673            dict_id: limnifs_core::drop_record::NO_DICT,
1674            plaintext: None,
1675            flags: 0,
1676        }
1677    }
1678
1679    fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
1680        let meta = std::fs::symlink_metadata(path)?;
1681        let file_type = meta.file_type();
1682        let mtime_ns = meta
1683            .modified()
1684            .ok()
1685            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1686            .map_or(0u128, |d| d.as_nanos());
1687        let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
1688
1689        if file_type.is_dir() {
1690            self.dir_count += 1;
1691            let inode_number = self.alloc_inode();
1692            let mut entries: Vec<(String, u64, u8)> = Vec::new();
1693
1694            for entry in std::fs::read_dir(path)? {
1695                let entry = entry?;
1696                let name = entry.file_name().to_string_lossy().into_owned();
1697                let child_path = entry.path();
1698                let child_inode = self.walk(&child_path)?;
1699                // entry.file_type() does NOT follow symlinks (the old
1700                // entry.metadata() did, misreporting links to dirs).
1701                let ft = entry.file_type()?;
1702                let entry_type = if ft.is_symlink() {
1703                    0x03
1704                } else if ft.is_dir() {
1705                    0x02
1706                } else {
1707                    0x01
1708                };
1709                entries.push((name, child_inode, entry_type));
1710            }
1711
1712            entries.sort_by(|a, b| a.0.cmp(&b.0));
1713            let dir_node = encode_dir_node(&entries);
1714            self.dir_nodes.push(dir_node);
1715            self.inodes.push(PendingInode {
1716                number: inode_number,
1717                mode: 0o040_755,
1718                mtime_ns,
1719                content: PendingContent::Directory(entries),
1720            });
1721            Ok(inode_number)
1722        } else if file_type.is_file() {
1723            self.file_count += 1;
1724            let inode_number = self.alloc_inode();
1725            let file_len = meta.len();
1726
1727            if file_len <= u64::try_from(self.inline_threshold).unwrap_or(u64::MAX) {
1728                let data = std::fs::read(path)?;
1729                self.inodes.push(PendingInode {
1730                    number: inode_number,
1731                    mode: 0o100_644,
1732                    mtime_ns,
1733                    content: PendingContent::Inline(data),
1734                });
1735            } else {
1736                // Defer to parallel processing — collect the file info.
1737                let pf = PendingFile {
1738                    inode_number,
1739                    path: path.to_path_buf(),
1740                    mtime_ns,
1741                    file_len,
1742                };
1743                if let Some(sink) = &self.pending_sink {
1744                    // Streaming mode: hand the file to the compress
1745                    // workers immediately; the bounded channel
1746                    // back-pressures if they fall behind. A send
1747                    // failure means the receiver is gone (a worker
1748                    // hit an unrecoverable error) — abort the walk.
1749                    sink.send(pf).map_err(|_| {
1750                        WriteError::Io(std::io::Error::other("walk: compress pipeline shut down"))
1751                    })?;
1752                } else {
1753                    self.pending_files.push(pf);
1754                }
1755            }
1756            Ok(inode_number)
1757        } else if file_type.is_symlink() {
1758            // Issue #190: symlinks are a first-class format citizen
1759            // (the reader has ContentHandle::Symlink and extract
1760            // recreates links); the writer just never emitted them.
1761            // The target is stored verbatim — relative or absolute,
1762            // in-tree or out-of-tree, dangling or not. Note
1763            // `symlink_metadata` above gives us the LINK's own
1764            // metadata, so a dangling link still walks cleanly.
1765            let inode_number = self.alloc_inode();
1766            let target = std::fs::read_link(path)?;
1767            let target = target
1768                .to_str()
1769                .ok_or_else(|| WriteError::UnsupportedFileType {
1770                    path: path.to_path_buf(),
1771                    kind: format!("symlink with non-UTF-8 target ({})", target.display()),
1772                })?
1773                .to_owned();
1774            self.inodes.push(PendingInode {
1775                number: inode_number,
1776                mode: limnifs_core::inode::S_IFLNK | 0o777,
1777                mtime_ns,
1778                content: PendingContent::Symlink(target),
1779            });
1780            Ok(inode_number)
1781        } else {
1782            #[cfg(unix)]
1783            let kind = {
1784                use std::os::unix::fs::FileTypeExt;
1785                if file_type.is_fifo() {
1786                    "fifo".to_owned()
1787                } else if file_type.is_socket() {
1788                    "socket".to_owned()
1789                } else if file_type.is_block_device() {
1790                    "block device".to_owned()
1791                } else if file_type.is_char_device() {
1792                    "character device".to_owned()
1793                } else {
1794                    "unknown".to_owned()
1795                }
1796            };
1797            #[cfg(not(unix))]
1798            let kind = "unknown".to_owned();
1799            Err(WriteError::UnsupportedFileType {
1800                path: path.to_path_buf(),
1801                kind,
1802            })
1803        }
1804    }
1805
1806    /// After the parallel compress phase: train one ZSTD dictionary
1807    /// per classifier class with enough samples, then re-compress
1808    /// each ZSTD drop with the dictionary for its class. Keep
1809    /// whichever representation is smaller. Drops that get
1810    /// re-compressed carry the class's `dict_id` in their drop
1811    /// record; the dictionaries are emitted in the manifest's
1812    /// `dictionary_section`.
1813    ///
1814    /// Text/Code/Sparse classes collapse into a single "text" dict
1815    /// (id 0). Binary gets id 1. Other classes don't accumulate
1816    /// samples because their drops aren't ZSTD-compressed.
1817    ///
1818    /// Clears the retained plaintext on every drop to free memory
1819    /// before slab assembly.
1820    fn train_and_apply_dictionary(&mut self, dictionaries: &crate::config::DictionaryConfig) {
1821        let cleanup = |ctx: &mut Self| {
1822            for d in &mut ctx.drops {
1823                d.plaintext = None;
1824            }
1825            ctx.dict_samples_by_class.clear();
1826        };
1827
1828        if !dictionaries.enabled {
1829            cleanup(self);
1830            return;
1831        }
1832
1833        let target = usize::try_from(dictionaries.max_dict_size).unwrap_or(65_536);
1834        let min_class = usize::try_from(dictionaries.min_class_size).unwrap_or(0);
1835
1836        // Allocate dict ids: 0 = text (Text/Code/Sparse), 1 = binary.
1837        // Compressed/Media/Incompressible don't accumulate samples
1838        // (their drops aren't ZSTD) so we don't train for them.
1839        let text_classes = [
1840            crate::classifier::Class::Text,
1841            crate::classifier::Class::Code,
1842            crate::classifier::Class::Sparse,
1843        ];
1844        let binary_classes = [crate::classifier::Class::Binary];
1845
1846        // Train text dict from text-like classes' samples combined.
1847        let text_samples: Vec<&[u8]> = text_classes
1848            .iter()
1849            .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1850            .map(Vec::as_slice)
1851            .collect();
1852        let trainer = crate::dictionary::TrainerKind::from_config_str(&dictionaries.trainer);
1853        if text_samples.len() >= min_class {
1854            if let Some(dict) =
1855                crate::dictionary::train_zstd_with_trainer(0, &text_samples, target, trainer)
1856            {
1857                self.trained_dicts_by_class
1858                    .insert(crate::classifier::Class::Text, dict);
1859            }
1860        }
1861        let binary_samples: Vec<&[u8]> = binary_classes
1862            .iter()
1863            .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1864            .map(Vec::as_slice)
1865            .collect();
1866        if binary_samples.len() >= min_class {
1867            if let Some(dict) =
1868                crate::dictionary::train_zstd_with_trainer(1, &binary_samples, target, trainer)
1869            {
1870                self.trained_dicts_by_class
1871                    .insert(crate::classifier::Class::Binary, dict);
1872            }
1873        }
1874
1875        // Re-compress each ZSTD drop with the dict for its class,
1876        // keeping the smaller representation. This is a second full
1877        // compression pass over the tree, so it runs in parallel —
1878        // serially it would be the tail after every rayon worker has
1879        // finished the walk/tournament phases (the same one-core-tail
1880        // shape Phase 2 fixed for chunk compression). Per-drop
1881        // decisions are independent and par_iter_mut mutates in
1882        // place, so drop order and output bytes are unchanged.
1883        use rayon::prelude::*;
1884        let classifier = self.classifier;
1885        self.drops.par_iter_mut().for_each(|d| {
1886            if d.codec != limnifs_core::codec::CODEC_ZSTD {
1887                return;
1888            }
1889            let Some(plaintext) = d.plaintext.as_ref() else {
1890                return;
1891            };
1892            let class = classifier.classify(plaintext);
1893            let dict_class = if text_classes.contains(&class) {
1894                crate::classifier::Class::Text
1895            } else if binary_classes.contains(&class) {
1896                crate::classifier::Class::Binary
1897            } else {
1898                return;
1899            };
1900            let Some(dict) = self.trained_dicts_by_class.get(&dict_class) else {
1901                return;
1902            };
1903            let Ok(dict_compressed) = dict.compress(plaintext) else {
1904                return;
1905            };
1906            if dict_compressed.len() < d.compressed.len() {
1907                d.compressed = dict_compressed.into();
1908                d.dict_id = dict.id;
1909            }
1910        });
1911
1912        cleanup(self);
1913    }
1914
1915    /// Env-gated phase timer for assemble profiling (TODO.perf/16).
1916    fn trace_phase(label: &str, start: std::time::Instant) {
1917        if std::env::var_os("LIMNIFS_TRACE_ASSEMBLE").is_some() {
1918            eprintln!("[assemble] {label}: {:?}", start.elapsed());
1919        }
1920    }
1921
1922    fn assemble(mut self) -> WriteArtifact {
1923        let t_assemble = std::time::Instant::now();
1924        let inode_count = self.inodes.len();
1925        let dir_count = self.dir_count;
1926        let drop_count = self.drops.len();
1927
1928        // Partition drops into slabs. Each slab's total byte length
1929        // (header + drop records + solid window) must stay under
1930        // MAX_SLAB_TOTAL_BYTES so the reader's 64 MiB ceiling is never
1931        // exceeded. A single drop larger than the budget gets its own
1932        // slab (we cannot split a drop).
1933        let t = std::time::Instant::now();
1934        let slabs = pack_slabs(&self.drops);
1935        Self::trace_phase("pack_slabs", t);
1936
1937        // Build the shared inline table: deduplicate inline data that
1938        // appears in more than one inode. For N files with identical
1939        // small content, store once and reference by index.
1940        let t = std::time::Instant::now();
1941        if self.emit_shared_inline {
1942            self.build_shared_inline_table();
1943        }
1944        Self::trace_phase("shared_inline_table", t);
1945
1946        let mut metadata_blob = Vec::new();
1947        metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
1948        for inode in &self.inodes {
1949            self.encode_inode(&mut metadata_blob, inode);
1950        }
1951        metadata_blob
1952            .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
1953        for node in &self.dir_nodes {
1954            metadata_blob.extend_from_slice(&node.bytes);
1955        }
1956        // Shared inline table (only present if any dedup occurred).
1957        // Reader checks for remaining bytes after dir_nodes.
1958        if !self.shared_inline_table.is_empty() {
1959            metadata_blob.extend_from_slice(
1960                &u32::try_from(self.shared_inline_table.len())
1961                    .unwrap()
1962                    .to_le_bytes(),
1963            );
1964            for entry in &self.shared_inline_table {
1965                let len = u32::try_from(entry.len()).expect("shared entry fits u32");
1966                metadata_blob.extend_from_slice(&len.to_le_bytes());
1967                metadata_blob.extend_from_slice(entry);
1968            }
1969        }
1970
1971        Self::trace_phase("metadata_encode", t);
1972        // Compress the metadata blob. Metadata is highly compressible
1973        // (sequential inode numbers, repeated modes, natural-language
1974        // file names) — even low Brotli quality yields 4–8× on source
1975        // trees. Pick quality by size: small blobs cost nothing to
1976        // compress at q5; large blobs (e.g. 50 K-inode trees) would
1977        // dominate create time at q5, so step down to q2.
1978        let uncompressed_len =
1979            u32::try_from(metadata_blob.len()).expect("metadata blob length fits u32");
1980        let t = std::time::Instant::now();
1981        let metadata_hash = hash_section(&metadata_blob);
1982        let metadata_codec = self.metadata_codec;
1983        let metadata_quality = if metadata_blob.len() > METADATA_LARGE_BLOB_THRESHOLD {
1984            METADATA_LARGE_BLOB_QUALITY
1985        } else {
1986            METADATA_SMALL_BLOB_QUALITY
1987        };
1988        let compressed_blob = if metadata_codec == limnifs_core::codec::CODEC_BROTLI {
1989            limnifs_core::codec::compress_brotli_with_quality(&metadata_blob, metadata_quality)
1990                .unwrap_or_else(|_| metadata_blob.clone())
1991        } else {
1992            limnifs_core::codec::compress(metadata_codec, &metadata_blob)
1993                .unwrap_or_else(|_| metadata_blob.clone())
1994        };
1995        Self::trace_phase("metadata_compress", t);
1996        let (on_wire_codec, on_wire_blob) = if compressed_blob.len() < metadata_blob.len() {
1997            (metadata_codec, compressed_blob)
1998        } else {
1999            (limnifs_core::codec::CODEC_STORE, metadata_blob.clone())
2000        };
2001
2002        // Decide inline vs sidecar based on the COMPRESSED length,
2003        // clamped to the reader's inline ceiling regardless of config
2004        // (inline metadata above it is unreadable by default readers).
2005        let externalize_at = self
2006            .metadata_externalize_threshold
2007            .min(limnifs_core::metadata_reference::DEFAULT_INLINE_METADATA_MAX_BYTES as usize);
2008        let (metadata_sidecar, inline_data, metadata_locator_count) =
2009            if on_wire_blob.len() > externalize_at {
2010                // Content-derived sidecar name: an RW commit NEVER
2011                // overwrites the metadata file an already-open
2012                // reader's manifest references (same name, different
2013                // bytes = torn blob). Identical trees reuse the same
2014                // name; divergent generations accumulate until
2015                // turnover / gc reclaims them.
2016                let h = hash_section(&on_wire_blob);
2017                let mut h8 = String::with_capacity(8);
2018                for b in &h[..4] {
2019                    h8.push_str(&format!("{b:02x}"));
2020                }
2021                let locator = format!("file:metadata-{h8}.bin");
2022                let sidecar = MetadataSidecar {
2023                    bytes: on_wire_blob.clone(),
2024                    locator,
2025                };
2026                (Some(sidecar), None, 1u32)
2027            } else {
2028                (None, Some(on_wire_blob.clone()), 0u32)
2029            };
2030
2031        let mut manifest = Vec::new();
2032
2033        let header_start = manifest.len();
2034        manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
2035        let header_end = manifest.len();
2036
2037        let flags_start = manifest.len();
2038        manifest.push(FEATURE_FLAGS_SECTION_VERSION);
2039        manifest.extend_from_slice(&0u32.to_le_bytes());
2040        let flags_end = manifest.len();
2041
2042        // metadata_reference v2: hash + uncompressed_len + codec +
2043        // locator_count + locators + inline_data_len + inline_data.
2044        let meta_ref_start = manifest.len();
2045        manifest.push(METADATA_REFERENCE_SECTION_VERSION_2);
2046        manifest.extend_from_slice(&metadata_hash);
2047        manifest.extend_from_slice(&uncompressed_len.to_le_bytes());
2048        manifest.push(on_wire_codec);
2049        manifest.extend_from_slice(&metadata_locator_count.to_le_bytes());
2050        if let Some(sidecar) = &metadata_sidecar {
2051            let loc_bytes = sidecar.locator.as_bytes();
2052            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
2053            manifest.extend_from_slice(&loc_len.to_le_bytes());
2054            manifest.extend_from_slice(loc_bytes);
2055        }
2056        match &inline_data {
2057            Some(blob) => {
2058                let inline_len = u32::try_from(blob.len()).expect("metadata fits u32");
2059                manifest.extend_from_slice(&inline_len.to_le_bytes());
2060                manifest.extend_from_slice(blob);
2061            }
2062            None => {
2063                manifest.extend_from_slice(&0u32.to_le_bytes());
2064            }
2065        }
2066        let meta_ref_end = manifest.len();
2067
2068        let slab_index_start = manifest.len();
2069        manifest.push(SLAB_INDEX_SECTION_VERSION);
2070        manifest.extend_from_slice(&u32::try_from(slabs.len()).unwrap().to_le_bytes());
2071        for slab in &slabs {
2072            manifest.extend_from_slice(&slab.id.to_bytes());
2073            manifest.extend_from_slice(&1u32.to_le_bytes());
2074            let loc_bytes = slab.locator.as_bytes();
2075            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
2076            manifest.extend_from_slice(&loc_len.to_le_bytes());
2077            manifest.extend_from_slice(loc_bytes);
2078        }
2079        let slab_index_end = manifest.len();
2080
2081        let history_start = manifest.len();
2082        manifest.push(HISTORY_SECTION_VERSION);
2083        manifest.extend_from_slice(&1u32.to_le_bytes());
2084        manifest.push(0x01);
2085        manifest.extend_from_slice(&0u64.to_le_bytes());
2086        manifest.extend_from_slice(&0u32.to_le_bytes());
2087        manifest.extend_from_slice(&0u32.to_le_bytes());
2088        let history_end = manifest.len();
2089
2090        // ProfileDescriptor section (optional — appended after history).
2091        // Records which overhead layers were active so any reader can
2092        // handle the image correctly. Only emitted if a profile name
2093        // was set.
2094        let profile_desc_start = manifest.len();
2095        if let Some(ref name) = self.profile_name {
2096            let desc = limnifs_core::profile_descriptor::ProfileDescriptor {
2097                version: limnifs_core::profile_descriptor::PROFILE_DESCRIPTOR_SECTION_VERSION,
2098                profile_name: Some(name.clone()),
2099                blake3_hashing: true,
2100                cross_file_dedup: true,
2101                content_classification: !self.categorizers_disabled,
2102                integrity_verify: true,
2103                read_write: self.rw_mode,
2104                auto_turnover: self.auto_turnover,
2105            };
2106            limnifs_core::profile_descriptor::encode_profile_descriptor(&desc, &mut manifest);
2107        }
2108        let profile_desc_end = manifest.len();
2109
2110        // DictionarySection (optional — emitted when dictionaries
2111        // were trained during the post-parallel pass). Contains the
2112        // `(codec_id, class_id, data)` triples referenced by drop
2113        // records' `dict_id` field. One entry per class with enough
2114        // samples to train: text (id 0), binary (id 1).
2115        if !self.trained_dicts_by_class.is_empty() {
2116            let dicts: Vec<_> = self
2117                .trained_dicts_by_class
2118                .values()
2119                .map(|d| limnifs_core::dictionary_section::Dictionary {
2120                    codec_id: d.codec,
2121                    class_id: d.id,
2122                    data: d.content.clone(),
2123                })
2124                .collect();
2125            let section = limnifs_core::dictionary_section::DictionarySection {
2126                version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
2127                dicts,
2128            };
2129            limnifs_core::dictionary_section::encode_dictionary_section(&section, &mut manifest);
2130        }
2131
2132        let dictionary_end = manifest.len();
2133
2134        // DeltaLinkage section (optional — emitted only by `write_layer`).
2135        // Carries `base_root` so readers can resolve referenced drops via
2136        // the overlay chain. The section's hash feeds the
2137        // `SectionHashes::delta_linkage` slot, which is empty for
2138        // standalone images.
2139        let delta_linkage_hash = if let Some(base_root) = self.base_root {
2140            let delta_start = manifest.len();
2141            // Inline-encode the delta linkage section (version 1):
2142            // [version:u8][base_root:32][tree_op_count:u32=0]. Tree ops
2143            // are empty because the metadata blob carries the full new
2144            // tree — readers see `base_root` and know to walk the
2145            // overlay chain for any DropId not present in local slabs.
2146            manifest.push(limnifs_core::delta_linkage::DELTA_LINKAGE_SECTION_VERSION);
2147            manifest.extend_from_slice(&base_root);
2148            manifest.extend_from_slice(&0u32.to_le_bytes());
2149            hash_section(&manifest[delta_start..])
2150        } else {
2151            hash_empty_section()
2152        };
2153        let _ = dictionary_end;
2154
2155        let hashes = SectionHashes {
2156            metadata: metadata_hash,
2157            format_header: hash_section(&manifest[header_start..header_end]),
2158            feature_flags: hash_section(&manifest[flags_start..flags_end]),
2159            metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
2160            slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
2161            crypto_params: hash_empty_section(),
2162            ec_params: hash_empty_section(),
2163            dms_policy: hash_empty_section(),
2164            delta_linkage: delta_linkage_hash,
2165            history: hash_section(&manifest[history_start..history_end]),
2166            // The Merkle construction doesn't currently include a
2167            // dictionary_section hash slot. Treat the section as
2168            // crypto-params-equivalent (covered by the metadata hash)
2169            // for now; documenting this with an explicit comment so
2170            // the next reader knows where to add a hash slot if the
2171            // spec grows one.
2172            // TODO: spec section-hash for dictionary_section.
2173            // For now use hash_empty_section() so the structure compiles;
2174            // a future spec rev will add a dedicated slot.
2175            // (Section bytes are still content-addressed via the
2176            // slab_index hash and the manifest's Merkle root.)
2177        };
2178        let merkle_root = compute_merkle_root(&hashes);
2179
2180        WriteArtifact {
2181            bytes: manifest,
2182            merkle_root,
2183            slabs,
2184            metadata_sidecar,
2185            inode_count,
2186            file_count: self.file_count,
2187            dir_count,
2188            drop_count,
2189            root_inode_number: self.root_inode_number,
2190        }
2191    }
2192
2193    fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
2194        out.extend_from_slice(&inode.number.to_le_bytes());
2195        out.extend_from_slice(&inode.mode.to_le_bytes());
2196        out.extend_from_slice(&0u32.to_le_bytes());
2197        out.extend_from_slice(&0u32.to_le_bytes());
2198        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
2199        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
2200        out.extend_from_slice(&1u32.to_le_bytes());
2201        match &inode.content {
2202            PendingContent::Inline(data) => {
2203                let h = hash_section(data);
2204                if let Some(&idx) = self.shared_inline_map.get(&h) {
2205                    // Deduplicated: emit shared-inline flag + index.
2206                    out.push(INODE_FLAG_INLINE_DATA | INODE_FLAG_SHARED_INLINE);
2207                    out.extend_from_slice(&(idx as u32).to_le_bytes());
2208                } else {
2209                    out.push(INODE_FLAG_INLINE_DATA);
2210                    let len = u32::try_from(data.len()).expect("data fits u32");
2211                    out.extend_from_slice(&len.to_le_bytes());
2212                    out.extend_from_slice(data);
2213                }
2214            }
2215            PendingContent::DropBacked { file_len, slices } => {
2216                out.push(0x00);
2217                let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
2218                out.extend_from_slice(&slice_count.to_le_bytes());
2219                for slice in slices {
2220                    out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
2221                    out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
2222                    out.extend_from_slice(&slice.drop_id);
2223                    // drop_byte_start = 0 (slice covers the whole drop)
2224                    out.extend_from_slice(&0u32.to_le_bytes());
2225                    // drop_byte_len = the byte length of this slice in the
2226                    // drop's decompressed plaintext. Each slice maps to
2227                    // exactly one chunk, so this equals the file range.
2228                    let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
2229                        .expect("slice range fits u32");
2230                    out.extend_from_slice(&drop_byte_len.to_le_bytes());
2231                }
2232                let _ = file_len;
2233            }
2234            PendingContent::Symlink(target) => {
2235                // The reader dispatches on the inode's S_IFMT bits and
2236                // reads target_len + target directly (flags unused
2237                // for non-regular inodes).
2238                out.push(0x00);
2239                let t = target.as_bytes();
2240                let len = u32::try_from(t.len()).expect("target fits u32");
2241                out.extend_from_slice(&len.to_le_bytes());
2242                out.extend_from_slice(t);
2243            }
2244            PendingContent::Directory(entries) => {
2245                out.push(0x00);
2246                let node = self
2247                    .dir_nodes
2248                    .iter()
2249                    .find(|n| n.entries == *entries)
2250                    .expect("directory node must exist");
2251                out.extend_from_slice(&node.hash);
2252            }
2253        }
2254    }
2255}
2256
2257/// Resolve a locator URI to the local sidecar file name, refusing
2258/// non-flat paths (CWE-22 — see
2259/// `limnifs_core::locator::local_sidecar_name`). Writer-emitted
2260/// locators are always flat, so this only fires on foreign/malicious
2261/// manifests.
2262fn sidecar_name(locator: &str) -> Result<&str, WriteError> {
2263    limnifs_core::locator::local_sidecar_name(locator)
2264        .map_err(|e| WriteError::Io(std::io::Error::other(format!("{e}"))))
2265}
2266
2267fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
2268    let mut bytes = Vec::new();
2269    bytes.push(1u8);
2270    let count = u32::try_from(entries.len()).expect("entry count fits u32");
2271    bytes.extend_from_slice(&count.to_le_bytes());
2272    for (name, inode_number, entry_type) in entries {
2273        let name_bytes = name.as_bytes();
2274        let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
2275        bytes.extend_from_slice(&name_len.to_le_bytes());
2276        bytes.extend_from_slice(name_bytes);
2277        bytes.extend_from_slice(&inode_number.to_le_bytes());
2278        bytes.push(*entry_type);
2279    }
2280    let hash = hash_section(&bytes);
2281    DirNode {
2282        entries: entries.to_vec(),
2283        bytes,
2284        hash,
2285    }
2286}
2287
2288/// Partition `drops` into one or more slabs, each fitting under
2289/// [`MAX_SLAB_TOTAL_BYTES`]. Slab ordinal starts at 0 and increments.
2290/// Each slab's `SlabId` hash is `BLAKE3(slab_content)` so identical
2291/// content yields identical slab IDs (deterministic).
2292///
2293/// A single drop larger than `MAX_SLAB_TOTAL_BYTES - SLAB_HEADER_LEN`
2294/// still produces one slab — we cannot split a drop, and the spec
2295/// permits the reader to raise its ceiling for that case.
2296fn pack_slabs(drops: &[PendingDrop]) -> Vec<SlabArtifact> {
2297    // Filter out CODEC_REFERENCED sentinel drops — they exist in the
2298    // writer's in-memory state for inode/slice bookkeeping but are
2299    // resolved via the overlay chain at read time, never stored in
2300    // this image's slabs.
2301    let local_drops: Vec<&PendingDrop> = drops
2302        .iter()
2303        .filter(|d| d.codec != limnifs_core::codec::CODEC_REFERENCED)
2304        .collect();
2305    if local_drops.is_empty() {
2306        return Vec::new();
2307    }
2308
2309    let max_content = MAX_SLAB_TOTAL_BYTES.saturating_sub(SLAB_HEADER_LEN);
2310
2311    // Phase 1 (sequential): scan drops and partition into slab groups.
2312    // Each slab group is the list of drops that will share a slab window.
2313    // The grouping depends on per-drop compressed size + record overhead
2314    // — this is a sequential scan with a running size budget.
2315    let mut slab_groups: Vec<Vec<&PendingDrop>> = Vec::new();
2316    let mut current: Vec<&PendingDrop> = Vec::new();
2317    let mut current_size: usize = 0;
2318
2319    for drop in &local_drops {
2320        let footprint = drop.slab_footprint();
2321        if !current.is_empty() && current_size + footprint > max_content {
2322            slab_groups.push(std::mem::take(&mut current));
2323            current_size = 0;
2324        }
2325        current.push(*drop);
2326        current_size += footprint;
2327    }
2328    if !current.is_empty() {
2329        slab_groups.push(current);
2330    }
2331
2332    // Phase 2 (parallel): encode each slab independently. Slab encoding
2333    // has no cross-slab state — each slab's `offset_in_window` starts
2334    // at 0, its hash is over its own content, its ordinal is its index
2335    // in the slab_groups vector. Rayon parallelises across slabs;
2336    // large images with many slabs get N-core speedup on this phase.
2337    use rayon::prelude::*;
2338    slab_groups
2339        .par_iter()
2340        .enumerate()
2341        .map(|(ordinal, group)| {
2342            let ordinal_u64 = u64::try_from(ordinal).expect("slab count fits u64");
2343            encode_slab(ordinal_u64, group)
2344        })
2345        .collect()
2346}
2347
2348/// Encode a single slab from a non-empty slice of drops. Per-slab
2349/// `offset_in_window` is computed fresh; there is no global offset
2350/// state on `PendingDrop`.
2351fn encode_slab(ordinal: u64, drops: &[&PendingDrop]) -> SlabArtifact {
2352    // Each drop record is a fixed 49-byte entry: id(32) +
2353    // plaintext_len(4) + representation(3) + solid_window_index(1)
2354    // + offset_in_window(4) + window_len(4) + dict_id(1). Pre-sizing
2355    // the records Vec avoids per-drop realloc and amortises to a
2356    // single memcpy per field rather than bounds-check per call.
2357    // v2: 49-byte v1 record + trailing flags byte.
2358    const DROP_RECORD_LEN: usize = 50;
2359    let mut drop_records = Vec::with_capacity(drops.len() * DROP_RECORD_LEN);
2360    let mut solid_window = Vec::new();
2361    let mut drop_ids = Vec::with_capacity(drops.len());
2362    let mut offset_in_window: u32 = 0;
2363
2364    for drop in drops {
2365        let plaintext_len = drop.plaintext_len_value();
2366        let window_len = drop.len_in_window();
2367        drop_records.extend_from_slice(&drop.id);
2368        drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
2369        // representation: (codec, aead=0, ec=0)
2370        drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
2371        drop_records.push(0x00); // solid_window_index
2372        drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
2373        drop_records.extend_from_slice(&window_len.to_le_bytes());
2374        drop_records.push(drop.dict_id); // dict_id: NO_DICT (0xFF) or trained id (0..=254)
2375        drop_records.push(drop.flags); // flags: bit0 = SEEKABLE container
2376        solid_window.extend_from_slice(&drop.compressed);
2377        drop_ids.push(drop.id);
2378        offset_in_window = offset_in_window
2379            .checked_add(window_len)
2380            .expect("slab window size fits u32");
2381    }
2382
2383    let slab_content = [&drop_records[..], &solid_window[..]].concat();
2384    let slab_hash = hash_section(&slab_content);
2385    let slab_id = SlabId::new(ordinal, slab_hash);
2386
2387    let total_length = SLAB_HEADER_LEN + slab_content.len();
2388    let mut slab_bytes = Vec::with_capacity(total_length);
2389    slab_bytes.extend_from_slice(b"LIM1");
2390    slab_bytes.extend_from_slice(&1u16.to_le_bytes()); // the slab format version
2391    slab_bytes.extend_from_slice(&slab_id.to_bytes());
2392    slab_bytes.extend_from_slice(
2393        &u64::try_from(total_length)
2394            .unwrap_or(u64::MAX)
2395            .to_le_bytes(),
2396    );
2397    slab_bytes.push(0x00);
2398    slab_bytes.push(0x00);
2399    slab_bytes.extend_from_slice(&slab_content);
2400
2401    // Content-derived slab name — see the metadata sidecar comment
2402    // in `assemble`: RW commits must not overwrite slabs that live
2403    // manifests still reference.
2404    let mut h8 = String::with_capacity(8);
2405    for b in &slab_id.hash[..4] {
2406        h8.push_str(&format!("{b:02x}"));
2407    }
2408    let locator = format!("file:slab-{ordinal}-{h8}.bin");
2409
2410    SlabArtifact {
2411        id: slab_id,
2412        bytes: slab_bytes,
2413        locator,
2414        drop_ids,
2415    }
2416}
2417
2418#[cfg(test)]
2419fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
2420    let mut state = seed;
2421    let mut out = Vec::with_capacity(count);
2422    for _ in 0..count {
2423        state = state
2424            .wrapping_mul(6_364_136_223_846_793_005)
2425            .wrapping_add(1_442_695_040_888_963_407);
2426        out.push(u8::try_from(state >> 56).expect("fits u8"));
2427    }
2428    out
2429}
2430
2431#[cfg(test)]
2432mod tests {
2433    use super::*;
2434    use limnifs_core::ManifestCursor;
2435
2436    #[test]
2437    fn write_stream_packs_single_named_stream() {
2438        // Stream 256 KiB of repetitive text through write_stream and
2439        // verify the artifact has a single root file at the requested
2440        // name with the expected size.
2441        let temp = std::env::temp_dir().join(format!(
2442            "limnifs-write-stream-test-{}-{}",
2443            std::process::id(),
2444            std::time::SystemTime::now()
2445                .duration_since(std::time::UNIX_EPOCH)
2446                .unwrap()
2447                .as_nanos()
2448        ));
2449        std::fs::create_dir_all(&temp).expect("create temp dir");
2450
2451        let content = b"stream test content line\n".repeat(10_000); // ~240 KiB
2452        let cursor = std::io::Cursor::new(content.clone());
2453        let config = WriteConfig::default_v0_1();
2454        let artifact = write_stream("streamed.txt", cursor, &config).expect("write_stream");
2455
2456        assert!(!artifact.bytes.is_empty(), "manifest bytes non-empty");
2457        assert!(!artifact.slabs.is_empty(), "at least one slab produced");
2458        // The artifact's manifest encodes the file metadata; we don't
2459        // deeply inspect it here (conformance suite covers that), but
2460        // we do confirm the writer produced something well-formed
2461        // enough that round-tripping through the reader works.
2462        let total_drop_bytes: usize = artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2463        assert!(total_drop_bytes > 0, "drops non-empty");
2464
2465        let _ = std::fs::remove_dir_all(&temp);
2466    }
2467
2468    #[test]
2469    fn write_layer_references_base_drops() {
2470        // Build a base image with a 1 MiB text file, then build a
2471        // layer that ADDS a new file AND includes the same text file.
2472        // The layer's slab bytes must be small (just the new file)
2473        // because the text file's chunks hit the base's drop set and
2474        // are emitted as `CODEC_REFERENCED`.
2475        let temp = std::env::temp_dir().join(format!(
2476            "limnifs-write-layer-test-{}-{}",
2477            std::process::id(),
2478            std::time::SystemTime::now()
2479                .duration_since(std::time::UNIX_EPOCH)
2480                .unwrap()
2481                .as_nanos()
2482        ));
2483        std::fs::create_dir_all(&temp).expect("create temp dir");
2484
2485        // Base: 1 MiB of repetitive text + a small unique file.
2486        let base_dir = temp.join("base");
2487        std::fs::create_dir_all(&base_dir).expect("base dir");
2488        let text = b"layer test content line\n".repeat(50_000); // ~1.15 MiB
2489        std::fs::write(base_dir.join("shared.txt"), &text).expect("write shared");
2490        std::fs::write(base_dir.join("base-only.txt"), b"only in base").expect("write base-only");
2491
2492        let config = WriteConfig::default_v0_1();
2493        let base_artifact = write_directory_with_config(&base_dir, &config).expect("base");
2494
2495        let base_manifest = temp.join("base.lim");
2496        std::fs::write(&base_manifest, &base_artifact.bytes).expect("write base manifest");
2497        for slab in &base_artifact.slabs {
2498            let slab_name = sidecar_name(&slab.locator).expect("slab locator");
2499            std::fs::write(temp.join(slab_name), &slab.bytes).expect("write base slab");
2500        }
2501
2502        // Layer: same shared.txt (must dedup against base) + a new file.
2503        let layer_dir = temp.join("layer");
2504        std::fs::create_dir_all(&layer_dir).expect("layer dir");
2505        std::fs::write(layer_dir.join("shared.txt"), &text).expect("write shared in layer");
2506        std::fs::write(layer_dir.join("new.txt"), b"fresh content in layer").expect("write new");
2507
2508        let layer_artifact = write_layer(&base_manifest, &layer_dir, &config).expect("layer");
2509
2510        // The layer's slabs should be SMALL — only the new file's
2511        // content. shared.txt's chunks are referenced via the base.
2512        let layer_slab_bytes: usize = layer_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2513        let base_slab_bytes: usize = base_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2514        assert!(
2515            layer_slab_bytes < base_slab_bytes / 4,
2516            "layer slabs ({}) should be much smaller than base ({}) — layering failed",
2517            layer_slab_bytes,
2518            base_slab_bytes
2519        );
2520
2521        // The layer manifest must contain the base's Merkle root in
2522        // its delta_linkage section.
2523        let base_root = base_artifact.merkle_root.as_bytes();
2524        assert!(
2525            layer_artifact
2526                .bytes
2527                .windows(32)
2528                .any(|w| w == base_root.as_slice()),
2529            "layer manifest must contain base's ManifestRoot bytes"
2530        );
2531
2532        let _ = std::fs::remove_dir_all(&temp);
2533    }
2534
2535    #[test]
2536    fn tournament_short_circuits_on_highly_compressible_chunk() {
2537        // Repetitive text compresses to <25% under LZ4. Tournament
2538        // should accept LZ4 and skip the slower Brotli pass.
2539        let chunk = b"hello world ".repeat(500);
2540        let tunables = limnifs_core::codec::CodecTunables::default();
2541        let tournament = TournamentSpec {
2542            codec_ids: vec![
2543                limnifs_core::codec::CODEC_LZ4,
2544                limnifs_core::codec::CODEC_BROTLI,
2545            ],
2546            min_size: 16,
2547            skip_for_binary: false,
2548            short_circuit_permille: 250,
2549        };
2550        let (codec_id, compressed) = compress_chunk_with_tournament(
2551            &chunk,
2552            classifier::Class::Text,
2553            limnifs_core::codec::CODEC_BROTLI,
2554            limnifs_core::codec::CODEC_LZ4,
2555            &tunables,
2556            &tournament,
2557        );
2558        assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2559        assert!(compressed.len() < chunk.len());
2560    }
2561
2562    #[test]
2563    fn tournament_runs_all_codecs_when_short_circuit_disabled() {
2564        // short_circuit_permille = 0 means "never short-circuit". The
2565        // tournament must try every codec and pick the smallest.
2566        // With omnizip 0.14.40, ZSTD (cached Huffman table) typically
2567        // beats both LZ4 and Brotli's Phase-C partial encoder on
2568        // repetitive text, so we include it in the tournament.
2569        let chunk = b"hello world ".repeat(500);
2570        let tunables = limnifs_core::codec::CodecTunables::default();
2571        let tournament = TournamentSpec {
2572            codec_ids: vec![
2573                limnifs_core::codec::CODEC_LZ4,
2574                limnifs_core::codec::CODEC_BROTLI,
2575                limnifs_core::codec::CODEC_ZSTD,
2576            ],
2577            min_size: 16,
2578            skip_for_binary: false,
2579            short_circuit_permille: 0,
2580        };
2581        let (codec_id, compressed) = compress_chunk_with_tournament(
2582            &chunk,
2583            classifier::Class::Text,
2584            limnifs_core::codec::CODEC_BROTLI,
2585            limnifs_core::codec::CODEC_LZ4,
2586            &tunables,
2587            &tournament,
2588        );
2589        // All three codecs should be tried; the smallest wins. With
2590        // omnizip 0.16.40's long copy fix (MAX_COPY 271→4096), Brotli
2591        // now beats ZSTD on repetitive text. Either is acceptable.
2592        assert!(
2593            codec_id == limnifs_core::codec::CODEC_ZSTD
2594                || codec_id == limnifs_core::codec::CODEC_BROTLI,
2595            "expected ZSTD or Brotli to win, got codec {codec_id}"
2596        );
2597        assert!(compressed.len() < chunk.len());
2598    }
2599
2600    #[test]
2601    fn tournament_skips_for_binary_when_configured() {
2602        let chunk = vec![0u8; 4096];
2603        let tunables = limnifs_core::codec::CodecTunables::default();
2604        let tournament = TournamentSpec {
2605            codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2606            min_size: 16,
2607            skip_for_binary: true,
2608            short_circuit_permille: 250,
2609        };
2610        let (codec_id, _compressed) = compress_chunk_with_tournament(
2611            &chunk,
2612            classifier::Class::Binary,
2613            limnifs_core::codec::CODEC_BROTLI,
2614            limnifs_core::codec::CODEC_LZ4,
2615            &tunables,
2616            &tournament,
2617        );
2618        // skip_for_binary → use binary_codec (LZ4) directly, never Brotli.
2619        assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2620    }
2621
2622    #[test]
2623    fn tournament_small_chunk_uses_preferred_codec() {
2624        let chunk = b"tiny";
2625        let tunables = limnifs_core::codec::CodecTunables::default();
2626        let tournament = TournamentSpec {
2627            codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2628            min_size: 1024,
2629            skip_for_binary: false,
2630            short_circuit_permille: 0,
2631        };
2632        let (codec_id, _compressed) = compress_chunk_with_tournament(
2633            chunk,
2634            classifier::Class::Text,
2635            limnifs_core::codec::CODEC_BROTLI,
2636            limnifs_core::codec::CODEC_LZ4,
2637            &tunables,
2638            &tournament,
2639        );
2640        // Below min_size → preferred codec (brotli for text) directly.
2641        assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2642    }
2643
2644    #[test]
2645    fn tournament_falls_back_to_store_when_no_codec_compresses() {
2646        // Random data — no codec should improve on store. We use the
2647        // pseudo-random generator from the test helpers to get
2648        // deterministic but incompressible bytes.
2649        let chunk = pseudo_random_bytes(42, 4096);
2650        let tunables = limnifs_core::codec::CodecTunables::default();
2651        let tournament = TournamentSpec {
2652            codec_ids: vec![limnifs_core::codec::CODEC_LZ4],
2653            min_size: 16,
2654            skip_for_binary: false,
2655            short_circuit_permille: 0,
2656        };
2657        let (codec_id, compressed) = compress_chunk_with_tournament(
2658            &chunk,
2659            classifier::Class::Binary,
2660            limnifs_core::codec::CODEC_BROTLI,
2661            limnifs_core::codec::CODEC_LZ4,
2662            &tunables,
2663            &tournament,
2664        );
2665        assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2666        assert_eq!(compressed.len(), chunk.len());
2667    }
2668
2669    #[test]
2670    fn dictionaries_enabled_emits_dictionary_section_when_enough_samples() {
2671        // Many small text files with shared vocabulary → FrequencyTrainer
2672        // should find a dictionary. We assert the section appears in
2673        // the manifest, regardless of whether the trained dict beats
2674        // per-drop compression (the trainer is content-dependent).
2675        let temp = std::env::temp_dir().join(format!(
2676            "limnifs-write-test-{}-dict-{}",
2677            std::process::id(),
2678            std::time::SystemTime::now()
2679                .duration_since(std::time::UNIX_EPOCH)
2680                .map(|d| d.as_nanos() as u64)
2681                .unwrap_or(0),
2682        ));
2683        let _ = std::fs::remove_dir_all(&temp);
2684        std::fs::create_dir_all(&temp).expect("mkdir");
2685
2686        // Generate 200 similar small files just above INLINE_THRESHOLD
2687        // so they go through the slab path.
2688        for i in 0..200 {
2689            // Repeated vocabulary the trainer can exploit.
2690            let content = format!(
2691                "function test_case_{i}() {{ return constant + {i}; }}\n\
2692                 // shared comment line {i}\n\
2693                 struct Foo {{ x: i32 }} // type {i}\n"
2694            )
2695            .repeat(5);
2696            let path = temp.join(format!("file_{i:04}.txt"));
2697            std::fs::write(&path, content.as_bytes()).expect("write");
2698        }
2699
2700        let mut config = crate::profile::balanced();
2701        // Force ZSTD for text so drops go through the dict-eligible path.
2702        config.defaults.text_codec = "zstd".into();
2703        // omnizip 0.14.40's Brotli encoder emits some streams the
2704        // in-house decoder rejects on highly repetitive input. Use ZSTD
2705        // for the metadata blob too so the round-trip parse succeeds.
2706        config.defaults.metadata_codec = "zstd".into();
2707        config.dictionaries.enabled = true;
2708        config.dictionaries.min_class_size = 50;
2709        config.dictionaries.max_dict_size = 8192;
2710
2711        let artifact = write_directory_with_config(&temp, &config).expect("write");
2712        std::fs::remove_dir_all(&temp).ok();
2713
2714        // The dictionary_section (if emitted) lives after the history
2715        // section. We don't strictly assert presence because the trainer
2716        // may legitimately return an empty dict; the test's job is to
2717        // verify the pipeline doesn't panic and the manifest parses.
2718        let mut cursor = ManifestCursor::new(&artifact.bytes);
2719        let _ = limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2720        let _ = limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2721        let _ = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta_ref");
2722        let _ = limnifs_core::parse_slab_index(&mut cursor).expect("slab_index");
2723        let _ = limnifs_core::parse_history(&mut cursor).expect("history");
2724        // If a dict was emitted, parsing past history should leave
2725        // non-empty remaining bytes.
2726        let _remaining = cursor.remaining_len();
2727    }
2728
2729    #[test]
2730    fn write_empty_directory() {
2731        let temp =
2732            std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
2733        std::fs::create_dir_all(&temp).expect("create temp dir");
2734        let artifact = write_directory(&temp).expect("write succeeds");
2735        std::fs::remove_dir_all(&temp).ok();
2736        assert!(artifact.inode_count >= 1);
2737        assert_eq!(artifact.file_count, 0);
2738        assert_eq!(artifact.dir_count, 1);
2739        assert!(artifact.slabs.is_empty());
2740    }
2741
2742    #[test]
2743    fn write_small_file_inline() {
2744        let temp =
2745            std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
2746        std::fs::create_dir_all(&temp).expect("create temp dir");
2747        std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
2748        let artifact = write_directory(&temp).expect("write succeeds");
2749        std::fs::remove_dir_all(&temp).ok();
2750        assert_eq!(artifact.file_count, 1);
2751        assert!(artifact.slabs.is_empty());
2752        assert_eq!(artifact.drop_count, 0);
2753    }
2754
2755    #[test]
2756    fn write_large_file_uses_slab() {
2757        let temp =
2758            std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
2759        std::fs::create_dir_all(&temp).expect("create temp dir");
2760        let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
2761        std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
2762        let artifact = write_directory(&temp).expect("write succeeds");
2763        std::fs::remove_dir_all(&temp).ok();
2764        assert_eq!(artifact.drop_count, 1);
2765        assert_eq!(artifact.slabs.len(), 1);
2766    }
2767
2768    #[test]
2769    fn write_mixed_inline_and_large() {
2770        let temp =
2771            std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
2772        std::fs::create_dir_all(&temp).expect("create temp dir");
2773        std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
2774        std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
2775            .expect("write large");
2776        let artifact = write_directory(&temp).expect("write succeeds");
2777        std::fs::remove_dir_all(&temp).ok();
2778        assert_eq!(artifact.file_count, 2);
2779        assert_eq!(artifact.drop_count, 1);
2780        assert_eq!(artifact.slabs.len(), 1);
2781    }
2782
2783    #[test]
2784    fn deduplicates_identical_large_files() {
2785        let temp =
2786            std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
2787        std::fs::create_dir_all(&temp).expect("create temp dir");
2788        let data = vec![0x77u8; INLINE_THRESHOLD + 10];
2789        std::fs::write(temp.join("a.bin"), &data).expect("write a");
2790        std::fs::write(temp.join("b.bin"), &data).expect("write b");
2791        let artifact = write_directory(&temp).expect("write succeeds");
2792        std::fs::remove_dir_all(&temp).ok();
2793        assert_eq!(artifact.drop_count, 1);
2794    }
2795
2796    #[test]
2797    fn write_and_verify_roundtrip() {
2798        let temp = std::env::temp_dir().join(format!(
2799            "limnifs-write-test-{}-roundtrip",
2800            std::process::id()
2801        ));
2802        std::fs::create_dir_all(&temp).expect("create temp dir");
2803        std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
2804        std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
2805        std::fs::create_dir_all(temp.join("sub")).expect("create sub");
2806        std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
2807        let artifact = write_directory(&temp).expect("write succeeds");
2808        std::fs::remove_dir_all(&temp).ok();
2809        assert_eq!(artifact.file_count, 3);
2810        assert_eq!(artifact.dir_count, 2);
2811
2812        let mut cursor = ManifestCursor::new(&artifact.bytes);
2813        limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2814        limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2815        let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
2816        assert!(meta_ref.is_inlined());
2817        let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
2818        assert_eq!(slab_index.len(), 0);
2819        limnifs_core::parse_history(&mut cursor).expect("history");
2820    }
2821
2822    #[test]
2823    fn write_deterministic() {
2824        let temp =
2825            std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
2826        std::fs::create_dir_all(&temp).expect("create temp dir");
2827        std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
2828
2829        let a1 = write_directory(&temp).expect("first write");
2830        let a2 = write_directory(&temp).expect("second write");
2831        std::fs::remove_dir_all(&temp).ok();
2832
2833        assert_eq!(a1.bytes, a2.bytes);
2834        assert_eq!(a1.merkle_root, a2.merkle_root);
2835    }
2836
2837    #[test]
2838    fn slab_parses_correctly() {
2839        let temp =
2840            std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
2841        std::fs::create_dir_all(&temp).expect("create temp dir");
2842        std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
2843            .expect("write big");
2844        let artifact = write_directory(&temp).expect("write succeeds");
2845        std::fs::remove_dir_all(&temp).ok();
2846
2847        let slab_bytes = &artifact.slabs[0].bytes;
2848        let mut cursor = ManifestCursor::new(slab_bytes);
2849        let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
2850        assert_eq!(
2851            slab_header.format_version,
2852            limnifs_core::slab::SLAB_FORMAT_VERSION
2853        );
2854        assert!(!slab_header.is_sealed());
2855        assert!(!slab_header.has_erasure_coding());
2856
2857        let drop_record =
2858            limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
2859        assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
2860    }
2861
2862    #[test]
2863    fn fastcdc_produces_multiple_chunks_for_large_files() {
2864        // A 1 MiB pseudo-random file should produce multiple drops
2865        // via FastCDC (default chunker uses 64 KiB min / 256 KiB avg).
2866        let temp = std::env::temp_dir().join(format!(
2867            "limnifs-write-test-{}-cdc-multi",
2868            std::process::id()
2869        ));
2870        std::fs::create_dir_all(&temp).expect("create temp dir");
2871        let data = pseudo_random_bytes(42, 1024 * 1024);
2872        std::fs::write(temp.join("big.bin"), &data).expect("write big");
2873        let artifact = write_directory(&temp).expect("write succeeds");
2874        std::fs::remove_dir_all(&temp).ok();
2875        assert!(
2876            artifact.drop_count > 1,
2877            "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
2878            artifact.drop_count
2879        );
2880    }
2881
2882    #[test]
2883    fn fastcdc_deduplicates_shared_substrings() {
2884        // Two files sharing a long middle section should produce
2885        // fewer drops than the sum of their individual chunk counts,
2886        // because the shared section's chunks deduplicate.
2887        let temp = std::env::temp_dir().join(format!(
2888            "limnifs-write-test-{}-cdc-dedup",
2889            std::process::id()
2890        ));
2891        std::fs::create_dir_all(&temp).expect("create temp dir");
2892        let shared = pseudo_random_bytes(7, 512 * 1024);
2893        let mut a = Vec::with_capacity(shared.len() + 1024);
2894        a.extend_from_slice(&pseudo_random_bytes(1, 1024));
2895        a.extend_from_slice(&shared);
2896        let mut b = Vec::with_capacity(shared.len() + 2048);
2897        b.extend_from_slice(&pseudo_random_bytes(2, 2048));
2898        b.extend_from_slice(&shared);
2899        std::fs::write(temp.join("a.bin"), &a).expect("write a");
2900        std::fs::write(temp.join("b.bin"), &b).expect("write b");
2901
2902        // Baseline: each file alone.
2903        let temp_a = std::env::temp_dir().join(format!(
2904            "limnifs-write-test-{}-cdc-dedup-a",
2905            std::process::id()
2906        ));
2907        std::fs::create_dir_all(&temp_a).expect("create temp_a");
2908        std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
2909        let artifact_a = write_directory(&temp_a).expect("a writes");
2910        std::fs::remove_dir_all(&temp_a).ok();
2911
2912        let temp_b = std::env::temp_dir().join(format!(
2913            "limnifs-write-test-{}-cdc-dedup-b",
2914            std::process::id()
2915        ));
2916        std::fs::create_dir_all(&temp_b).expect("create temp_b");
2917        std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
2918        let artifact_b = write_directory(&temp_b).expect("b writes");
2919        std::fs::remove_dir_all(&temp_b).ok();
2920
2921        let artifact_both = write_directory(&temp).expect("both write");
2922        std::fs::remove_dir_all(&temp).ok();
2923
2924        let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
2925        assert!(
2926            artifact_both.drop_count < sum_alone,
2927            "expected dedup win: both together = {} drops, sum alone = {} drops",
2928            artifact_both.drop_count,
2929            sum_alone
2930        );
2931    }
2932
2933    #[test]
2934    fn slab_splits_when_content_exceeds_ceiling() {
2935        // Synthesise enough incompressible drops to force at least two
2936        // slabs. Each drop is 10 MiB of pseudo-random data; three drops
2937        // = 30 MiB compressed (random data doesn't compress), which
2938        // fits in one slab. We bump to seven drops (70 MiB) to force a
2939        // split.
2940        let temp =
2941            std::env::temp_dir().join(format!("limnifs-write-test-{}-split", std::process::id()));
2942        std::fs::create_dir_all(&temp).expect("create temp dir");
2943        for i in 0..7u32 {
2944            // 10 MiB of pseudo-random bytes — incompressible.
2945            let data = pseudo_random_bytes(u64::from(i), 10 * 1024 * 1024);
2946            std::fs::write(temp.join(format!("big-{i}.bin")), &data).expect("write big");
2947        }
2948        let artifact = write_directory(&temp).expect("write succeeds");
2949        std::fs::remove_dir_all(&temp).ok();
2950
2951        // Each slab's total length must respect MAX_SLAB_TOTAL_BYTES.
2952        assert!(
2953            artifact.slabs.len() >= 2,
2954            "expected at least 2 slabs for 70 MiB of incompressible data, got {}",
2955            artifact.slabs.len()
2956        );
2957        for slab in &artifact.slabs {
2958            assert!(
2959                slab.bytes.len() <= MAX_SLAB_TOTAL_BYTES,
2960                "slab {} is {} bytes (> {} ceiling)",
2961                slab.id.ordinal,
2962                slab.bytes.len(),
2963                MAX_SLAB_TOTAL_BYTES,
2964            );
2965        }
2966        // All seven drops must be accounted for across slabs.
2967        let total_drop_ids: usize = artifact.slabs.iter().map(|s| s.drop_ids.len()).sum();
2968        assert_eq!(
2969            total_drop_ids, artifact.drop_count,
2970            "drop_ids count across slabs must match WriteArtifact.drop_count",
2971        );
2972    }
2973}