Skip to main content

limnifs_write/
stream.rs

1//! Streaming multi-file writer — the no-materialisation seam.
2//!
3//! `write_stream` packs ONE named stream; [`StreamWriter`]
4//! generalises it to a whole tree of streams (tar archives, pipe
5//! bundles, network feeds) without ever touching the filesystem:
6//! entries are chunked straight off their readers via
7//! [`Chunker::chunk_reader`], whose internal buffering is bounded by
8//! the chunker's max chunk size plus one read buffer.
9//!
10//! ## Tree construction
11//!
12//! Entries arrive in arbitrary order; the tree is a nested
13//! `BTreeMap` so directory entries materialise name-sorted at
14//! `finish`. File and symlink inodes are allocated (and pushed) in
15//! arrival order; directory inodes are allocated parent-first
16//! during `finish`. The numbering therefore differs from a
17//! directory pack of the same tree (where the DFS orders
18//! allocation) — the format only requires unique numbers — but the
19//! same entry sequence always produces byte-identical images.
20//!
21//! [`Chunker::chunk_reader`]: crate::chunker::Chunker::chunk_reader
22
23use std::collections::BTreeMap;
24use std::io::Read;
25use std::path::PathBuf;
26
27use crate::chunker::Chunker;
28use crate::classifier;
29use crate::config::WriteConfig;
30use crate::{
31    encode_dir_node, hash_section, PendingContent, PendingFile, PendingInode, TournamentSpec,
32    WriteArtifact, WriteContext, WriteError,
33};
34
35/// Codec setup shared by every entry of one stream write. Built
36/// once at construction so per-entry cost is pure chunk + compress.
37struct StreamCodecs {
38    chunker: crate::chunker::ParallelFastCDC,
39    classifier: classifier::Classifier,
40    text_codec: u8,
41    binary_codec: u8,
42    tunables: limnifs_core::codec::CodecTunables,
43    tournament: TournamentSpec,
44}
45
46impl StreamCodecs {
47    fn from_config(
48        chunker: crate::chunker::ParallelFastCDC,
49        classifier: classifier::Classifier,
50        config: &WriteConfig,
51    ) -> Result<Self, WriteError> {
52        let registry = config
53            .codec_registry()
54            .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
55        let tournament_codec_ids: Vec<u8> = config
56            .tournament
57            .codecs
58            .iter()
59            .filter_map(|n| registry.lookup_by_name(n))
60            .collect();
61        Ok(Self {
62            chunker,
63            classifier,
64            text_codec: config.text_codec_id().unwrap_or(0x04),
65            binary_codec: config.binary_codec_id().unwrap_or(0x01),
66            tunables: config.to_core_tunables(),
67            tournament: TournamentSpec {
68                codec_ids: tournament_codec_ids,
69                min_size: config.tournament.min_size_threshold as usize,
70                skip_for_binary: config.tournament.skip_for_binary,
71                short_circuit_permille: config.tournament.short_circuit_threshold,
72            },
73        })
74    }
75}
76
77/// One entry's unix identity, applied to its emitted inode:
78/// nanosecond mtime, permission bits, and owner. Replaces the
79/// scattered `(mtime_ns, perms)` pairs — ownership is identity,
80/// not an afterthought (the tar headers carry it first-class and
81/// the directory writer captures it since v0.3.37).
82#[derive(Clone, Copy, Debug)]
83pub struct EntryMeta {
84    pub mtime_ns: u64,
85    pub perms: u32,
86    pub uid: u32,
87    pub gid: u32,
88}
89
90impl EntryMeta {
91    /// Identity with root ownership; chain `.owner` for real uid/gid.
92    #[must_use]
93    pub const fn new(mtime_ns: u64, perms: u32) -> Self {
94        Self {
95            mtime_ns,
96            perms,
97            uid: 0,
98            gid: 0,
99        }
100    }
101
102    /// Set the owner.
103    #[must_use]
104    pub const fn owner(mut self, uid: u32, gid: u32) -> Self {
105        self.uid = uid;
106        self.gid = gid;
107        self
108    }
109}
110
111/// A directory level under construction: children in name order,
112/// plus the identity carried by an explicit `add_dir` (implicit
113/// directories created by a nested file path keep mtime 0 / root).
114struct StreamDir {
115    meta: EntryMeta,
116    children: BTreeMap<String, StreamNode>,
117}
118
119impl Default for StreamDir {
120    fn default() -> Self {
121        Self {
122            meta: EntryMeta::new(0, 0o755),
123            children: BTreeMap::new(),
124        }
125    }
126}
127
128enum StreamNode {
129    Dir(StreamDir),
130    /// Inode already pushed; the number wires the tree at `finish`.
131    File {
132        inode_number: u64,
133    },
134    Symlink {
135        inode_number: u64,
136    },
137    /// A hardlink whose target resolves at `finish`, once the whole
138    /// tree exists — the tar format lets a Link member precede the
139    /// regular entry it shares an inode with.
140    DeferredLink {
141        target: String,
142    },
143}
144
145/// Build one `.lim` image from a sequence of named streams.
146///
147/// Create with [`StreamWriter::new`], add entries in any order
148/// ([`add_file`], [`add_dir`], [`add_symlink`]), then [`finish`] to
149/// assemble the artifact. Names are `/`-separated image-relative
150/// paths; parent directories are materialised implicitly, or
151/// explicitly via [`add_dir`] to control mtimes and empty
152/// directories.
153///
154/// [`add_file`]: Self::add_file
155/// [`add_dir`]: Self::add_dir
156/// [`add_symlink`]: Self::add_symlink
157/// [`finish`]: Self::finish
158///
159/// # Errors
160///
161/// [`WriteError::Io`] on reader failure, invalid names, conflicting
162/// paths, or any writer-pipeline error.
163pub struct StreamWriter<'a> {
164    ctx: WriteContext,
165    config: &'a WriteConfig,
166    codecs: StreamCodecs,
167    inline_threshold: u64,
168    tree: StreamDir,
169    /// Random-access entries awaiting the parallel flush at
170    /// `finish` (see [`stage_file`]). Flushed in stage order, after
171    /// every immediate `add_*` call.
172    ///
173    /// [`stage_file`]: Self::stage_file
174    staged: Vec<StagedEntry<'a>>,
175}
176
177/// One deferred stream entry: the tree slot and inode exist; only
178/// the chunk/hash/compress work is pending.
179struct StagedEntry<'a> {
180    name: String,
181    meta: EntryMeta,
182    xattrs: Vec<limnifs_core::inode::XAttr>,
183    inode_number: u64,
184    data: &'a [u8],
185}
186
187impl<'a> StreamWriter<'a> {
188    /// Start a stream write under `config`.
189    ///
190    /// # Errors
191    ///
192    /// [`WriteError::Io`] if the config's codec registry or chunking
193    /// parameters are invalid.
194    pub fn new(config: &'a WriteConfig) -> Result<Self, WriteError> {
195        let mut ctx = WriteContext::new();
196        ctx.chunker = crate::chunker_from_config(config)?;
197        ctx.categorizers_disabled = config.categorizers.is_empty();
198        ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
199        ctx.auto_turnover = config.turnover_threshold > 0;
200        ctx.collect_dict_samples = config.dictionaries.enabled;
201        ctx.inline_threshold = config.defaults.inline_threshold as usize;
202        ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
203        ctx.emit_shared_inline = config.defaults.shared_inline;
204        let classifier = ctx.classifier;
205        let chunker = ctx.chunker.clone();
206        Ok(Self {
207            codecs: StreamCodecs::from_config(chunker, classifier, config)?,
208            inline_threshold: u64::try_from(ctx.inline_threshold).unwrap_or(u64::MAX),
209            ctx,
210            config,
211            tree: StreamDir::default(),
212            staged: Vec::new(),
213        })
214    }
215
216    /// Add a regular file at `name`, streaming `reader` through the
217    /// chunker. Small entries (within the config's inline
218    /// threshold) are stored inline, matching the directory writer.
219    ///
220    /// # Errors
221    ///
222    /// [`WriteError::Io`] if the name is invalid or conflicts with
223    /// an existing entry, or the reader fails.
224    pub fn add_file(
225        &mut self,
226        name: &str,
227        meta: EntryMeta,
228        xattrs: &[(String, Vec<u8>)],
229        reader: &mut dyn Read,
230    ) -> Result<(), WriteError> {
231        let (parent, leaf) = descend(&mut self.tree, name)?;
232        if parent.children.contains_key(leaf) {
233            return Err(name_conflict(name));
234        }
235        let inode_number = self.ctx.alloc_inode();
236        let pf = PendingFile {
237            path: PathBuf::from(name),
238            inode_number,
239            file_len: 0,
240            mtime_ns: meta.mtime_ns,
241            mode: limnifs_core::inode::S_IFREG | (meta.perms & 0o7777),
242            uid: meta.uid,
243            gid: meta.gid,
244        };
245        let wire_xattrs = to_wire_xattrs(xattrs)?;
246        self.ctx.pending_files.push(pf.clone());
247
248        let chunks = self.codecs.chunker.chunk_reader(reader)?;
249        let total_len: u64 = chunks.iter().map(|c| c.len() as u64).sum();
250        self.ctx.file_count += 1;
251
252        if total_len <= self.inline_threshold {
253            let mut data = Vec::with_capacity(total_len as usize);
254            for chunk in &chunks {
255                data.extend_from_slice(chunk);
256            }
257            self.ctx.inodes.push(PendingInode {
258                number: inode_number,
259                mode: limnifs_core::inode::S_IFREG | (meta.perms & 0o7777),
260                uid: meta.uid,
261                gid: meta.gid,
262                mtime_ns: meta.mtime_ns,
263                xattrs: wire_xattrs,
264                content: PendingContent::Inline(data),
265            });
266        } else {
267            let mut drops = Vec::with_capacity(chunks.len());
268            let mut slices = Vec::with_capacity(chunks.len());
269            let mut offset: u64 = 0;
270            for chunk in &chunks {
271                let drop_id = hash_section(chunk);
272                slices.push(crate::PendingSlice {
273                    drop_id,
274                    file_byte_start: offset,
275                    file_byte_end: offset + chunk.len() as u64,
276                });
277                offset += chunk.len() as u64;
278                let class = self.codecs.classifier.classify(chunk);
279                let (codec_id, compressed) = crate::compress_chunk_with_tournament(
280                    chunk,
281                    class,
282                    self.codecs.text_codec,
283                    self.codecs.binary_codec,
284                    &self.codecs.tunables,
285                    &self.codecs.tournament,
286                );
287                drops.push((drop_id, chunk.clone(), compressed, codec_id, 0));
288            }
289            self.ctx
290                .merge_chunked_file(&pf, crate::ChunkedFileResult { drops, slices });
291            // merge_chunked_file read file_len from the placeholder;
292            // correct the just-pushed inode now that it is known.
293            if let Some(inode) = self.ctx.inodes.last_mut() {
294                if let PendingContent::DropBacked { file_len, .. } = &mut inode.content {
295                    *file_len = total_len;
296                }
297            }
298            self.ctx
299                .pending_files
300                .last_mut()
301                .expect("pushed above")
302                .file_len = total_len;
303        }
304        parent
305            .children
306            .insert(leaf.to_owned(), StreamNode::File { inode_number });
307        Ok(())
308    }
309
310    /// Stage a random-access entry for parallel packing: like
311    /// [`add_file`], but the data is an in-memory slice (e.g. an
312    /// mmap'd archive entry) whose byte range is already known, so
313    /// chunk/hash/compress work is deferred to [`finish`], which
314    /// fans the staged set across rayon workers and merges the
315    /// results serially in stage order. Same entries, same order →
316    /// byte-identical image to the serial `add_file` path.
317    ///
318    /// The borrow of `data` must outlive the writer.
319    ///
320    /// # Errors
321    ///
322    /// [`WriteError::Io`] if the name is invalid or conflicts with
323    /// an existing entry.
324    pub fn stage_file(
325        &mut self,
326        name: &str,
327        meta: EntryMeta,
328        xattrs: &[(String, Vec<u8>)],
329        data: &'a [u8],
330    ) -> Result<(), WriteError> {
331        let (parent, leaf) = descend(&mut self.tree, name)?;
332        if parent.children.contains_key(leaf) {
333            return Err(name_conflict(name));
334        }
335        let inode_number = self.ctx.alloc_inode();
336        self.ctx.file_count += 1;
337        crate::progress::emit_file(std::path::Path::new(name), data.len() as u64);
338        parent
339            .children
340            .insert(leaf.to_owned(), StreamNode::File { inode_number });
341        self.staged.push(StagedEntry {
342            name: name.to_owned(),
343            meta,
344            xattrs: to_wire_xattrs(xattrs)?,
345            inode_number,
346            data,
347        });
348        Ok(())
349    }
350
351    /// Add (or declare) a directory at `name` with the given
352    /// identity. Implicit parents created by nested entries keep
353    /// mtime 0 / root; calling this on an existing implicit
354    /// directory stamps it.
355    ///
356    /// # Errors
357    ///
358    /// [`WriteError::Io`] if the name is invalid or conflicts with
359    /// a non-directory entry.
360    pub fn add_dir(&mut self, name: &str, meta: EntryMeta) -> Result<(), WriteError> {
361        if name == "/" {
362            return Ok(()); // the root is materialised at finish
363        }
364        let (parent, leaf) = descend(&mut self.tree, name)?;
365        match parent.children.get_mut(leaf) {
366            None => {
367                parent.children.insert(
368                    leaf.to_owned(),
369                    StreamNode::Dir(StreamDir {
370                        meta,
371                        children: BTreeMap::new(),
372                    }),
373                );
374                Ok(())
375            }
376            Some(StreamNode::Dir(dir)) => {
377                dir.meta.mtime_ns = meta.mtime_ns;
378                Ok(())
379            }
380            Some(_) => Err(name_conflict(name)),
381        }
382    }
383
384    /// Add a hardlink at `name` referencing the file already added
385    /// (by any path method) at `target` — both names share one
386    /// inode, and the emitted inode carries the real nlink. The
387    /// target must exist in the tree and be a regular file.
388    ///
389    /// # Errors
390    ///
391    /// [`WriteError::Io`] if `name` is invalid or conflicting, the
392    /// target is missing, or the target is not a regular file.
393    pub fn add_hardlink(&mut self, name: &str, target: &str) -> Result<(), WriteError> {
394        let (parent, leaf) = descend(&mut self.tree, name)?;
395        if parent.children.contains_key(leaf) {
396            return Err(name_conflict(name));
397        }
398        // Deferred: the target may not exist yet (the tar format
399        // permits a Link member before its target). `finish`
400        // resolves every deferred link against the completed tree.
401        parent.children.insert(
402            leaf.to_owned(),
403            StreamNode::DeferredLink {
404                target: target.to_owned(),
405            },
406        );
407        Ok(())
408    }
409
410    /// Add a symbolic link at `name` pointing at `target` (stored
411    /// raw, exactly as given).
412    ///
413    /// # Errors
414    ///
415    /// [`WriteError::Io`] if the name is invalid or conflicts with
416    /// an existing entry.
417    pub fn add_symlink(
418        &mut self,
419        name: &str,
420        target: &str,
421        meta: EntryMeta,
422    ) -> Result<(), WriteError> {
423        let (parent, leaf) = descend(&mut self.tree, name)?;
424        if parent.children.contains_key(leaf) {
425            return Err(name_conflict(name));
426        }
427        let inode_number = self.ctx.alloc_inode();
428        self.ctx.inodes.push(PendingInode {
429            number: inode_number,
430            mode: limnifs_core::inode::S_IFLNK | (meta.perms & 0o7777),
431            uid: meta.uid,
432            gid: meta.gid,
433            mtime_ns: meta.mtime_ns,
434            xattrs: Vec::new(),
435            content: PendingContent::Symlink(target.to_owned()),
436        });
437        parent
438            .children
439            .insert(leaf.to_owned(), StreamNode::Symlink { inode_number });
440        Ok(())
441    }
442
443    /// Materialise the tree and assemble the image.
444    ///
445    /// # Errors
446    ///
447    /// [`WriteError::Io`] on any writer-pipeline error.
448    pub fn finish(mut self) -> Result<WriteArtifact, WriteError> {
449        self.flush_staged()?;
450        let mut tree = std::mem::take(&mut self.tree);
451        resolve_deferred_links(&mut tree, &mut self.ctx)?;
452        self.ctx.root_inode_number = self.materialize_dir(tree)?;
453        self.ctx
454            .train_and_apply_dictionary(&self.config.dictionaries);
455        Ok(self.ctx.assemble())
456    }
457
458    /// Chunk/hash/compress every staged entry across rayon workers,
459    /// then merge serially in stage order. The parallel map is
460    /// order-preserving and the merge replays the exact same
461    /// per-entry steps as [`add_file`], so output is identical to
462    /// the serial path. Large entries additionally hit the
463    /// boundary-identical parallel FastCDC inside their slice —
464    /// nested rayon, the same work-stealing shape the write
465    /// pipeline already uses.
466    fn flush_staged(&mut self) -> Result<(), WriteError> {
467        if self.staged.is_empty() {
468            return Ok(());
469        }
470        let staged = std::mem::take(&mut self.staged);
471        let codecs = &self.codecs;
472        use rayon::prelude::*;
473        let results: Vec<crate::ChunkedFileResult> = staged
474            .par_iter()
475            .map(|entry| {
476                let chunks: Vec<&[u8]> = codecs.chunker.chunk_slice(entry.data);
477                let mut drops = Vec::with_capacity(chunks.len());
478                let mut slices = Vec::with_capacity(chunks.len());
479                let mut offset: u64 = 0;
480                for chunk in &chunks {
481                    let drop_id = crate::hash_section(chunk);
482                    slices.push(crate::PendingSlice {
483                        drop_id,
484                        file_byte_start: offset,
485                        file_byte_end: offset + chunk.len() as u64,
486                    });
487                    offset += chunk.len() as u64;
488                    let class = codecs.classifier.classify(chunk);
489                    let (codec_id, compressed) = crate::compress_chunk_with_tournament(
490                        chunk,
491                        class,
492                        codecs.text_codec,
493                        codecs.binary_codec,
494                        &codecs.tunables,
495                        &codecs.tournament,
496                    );
497                    drops.push((drop_id, (*chunk).to_vec(), compressed, codec_id, 0));
498                }
499                crate::ChunkedFileResult { drops, slices }
500            })
501            .collect();
502        for (entry, result) in staged.iter().zip(results) {
503            // Unlike the streaming path, the length is known upfront,
504            // so no post-merge inode patching is needed.
505            let total_len = entry.data.len() as u64;
506            let pf = PendingFile {
507                path: std::path::PathBuf::from(&entry.name),
508                inode_number: entry.inode_number,
509                file_len: total_len,
510                mtime_ns: entry.meta.mtime_ns,
511                mode: limnifs_core::inode::S_IFREG | (entry.meta.perms & 0o7777),
512                uid: entry.meta.uid,
513                gid: entry.meta.gid,
514            };
515            self.ctx.pending_files.push(pf.clone());
516            if total_len <= self.inline_threshold {
517                // Below the inline threshold chunk_slice yields the
518                // whole entry as one chunk, so this equals the
519                // serial path's chunk concatenation.
520                let mut data = Vec::with_capacity(entry.data.len());
521                data.extend_from_slice(entry.data);
522                self.ctx.inodes.push(PendingInode {
523                    number: entry.inode_number,
524                    mode: limnifs_core::inode::S_IFREG | (entry.meta.perms & 0o7777),
525                    uid: entry.meta.uid,
526                    gid: entry.meta.gid,
527                    mtime_ns: entry.meta.mtime_ns,
528                    xattrs: entry.xattrs.clone(),
529                    content: PendingContent::Inline(data),
530                });
531            } else {
532                if !entry.xattrs.is_empty() {
533                    self.ctx
534                        .inode_xattrs
535                        .insert(entry.inode_number, entry.xattrs.clone());
536                }
537                self.ctx.merge_chunked_file(&pf, result);
538            }
539        }
540        Ok(())
541    }
542
543    /// Allocate this directory's inode, then recurse into children
544    /// in name order — parent-first, mirroring the directory walk.
545    fn materialize_dir(&mut self, dir: StreamDir) -> Result<u64, WriteError> {
546        let inode_number = self.ctx.alloc_inode();
547        self.ctx.dir_count += 1;
548        let mut entries = Vec::with_capacity(dir.children.len());
549        for (name, node) in dir.children {
550            let (child_inode, entry_type) = match node {
551                StreamNode::Dir(child) => (self.materialize_dir(child)?, 0x02),
552                StreamNode::File { inode_number } => (inode_number, 0x01),
553                StreamNode::Symlink { inode_number } => (inode_number, 0x03),
554                StreamNode::DeferredLink { .. } => {
555                    return Err(WriteError::Io(std::io::Error::other(
556                        "internal: unresolved hardlink reached materialization",
557                    )));
558                }
559            };
560            entries.push((name, child_inode, entry_type));
561        }
562        // BTreeMap iterates name-sorted; the explicit sort keeps the
563        // invariant local, exactly like fold_survey.
564        entries.sort_by(|a, b| a.0.cmp(&b.0));
565        self.ctx.dir_nodes.push(encode_dir_node(&entries));
566        self.ctx.inodes.push(PendingInode {
567            number: inode_number,
568            mode: limnifs_core::inode::S_IFDIR | (dir.meta.perms & 0o7777),
569            uid: dir.meta.uid,
570            gid: dir.meta.gid,
571            mtime_ns: dir.meta.mtime_ns,
572            xattrs: Vec::new(),
573            content: PendingContent::Directory(entries),
574        });
575        Ok(inode_number)
576    }
577}
578
579/// Walk (creating implicit directories) to `name`'s parent and
580/// return it plus the leaf component.
581fn descend<'a, 'b>(
582    root: &'a mut StreamDir,
583    name: &'b str,
584) -> Result<(&'a mut StreamDir, &'b str), WriteError> {
585    if name.is_empty() || name.starts_with('/') || name.ends_with('/') {
586        return Err(bad_name(name));
587    }
588    // `\` is a path separator on Windows and NUL is an io error:
589    // both must fail loudly at pack time, or the image they
590    // produce escapes the extract root on Windows binaries.
591    if name.contains(['\\', '\0']) {
592        return Err(bad_name(name));
593    }
594    let mut dir = root;
595    let mut components = name.split('/').peekable();
596    let leaf = components.next_back().expect("non-empty name has a leaf");
597    for component in components {
598        if component.is_empty() || component == "." || component == ".." {
599            return Err(bad_name(name));
600        }
601        dir = match dir
602            .children
603            .entry(component.to_owned())
604            .or_insert_with(|| StreamNode::Dir(StreamDir::default()))
605        {
606            StreamNode::Dir(child) => child,
607            StreamNode::File { .. }
608            | StreamNode::Symlink { .. }
609            | StreamNode::DeferredLink { .. } => return Err(name_conflict(name)),
610        };
611    }
612    if leaf.is_empty() || leaf == "." || leaf == ".." {
613        return Err(bad_name(name));
614    }
615    Ok((dir, leaf))
616}
617
618/// Resolve every deferred hardlink against the completed tree:
619/// collect targets in tree order (deterministic), chase each to a
620/// regular file's inode (a target may itself be a deferred link;
621/// cycles are refused), then replace the placeholders and bump
622/// nlink in the same order. A target-first tar therefore packs
623/// byte-identically to the call-order the old eager resolution
624/// produced.
625fn resolve_deferred_links(root: &mut StreamDir, ctx: &mut WriteContext) -> Result<(), WriteError> {
626    fn bad(target: &str) -> WriteError {
627        WriteError::Io(std::io::Error::other(format!(
628            "hardlink target {target:?} is not a file in the tree"
629        )))
630    }
631
632    fn collect(dir: &StreamDir, out: &mut Vec<String>) {
633        for node in dir.children.values() {
634            match node {
635                StreamNode::DeferredLink { target } => out.push(target.clone()),
636                StreamNode::Dir(child) => collect(child, out),
637                _ => {}
638            }
639        }
640    }
641
642    fn chase(root: &StreamDir, target: &str, seen: &mut Vec<String>) -> Result<u64, WriteError> {
643        if seen.iter().any(|s| s == target) {
644            return Err(bad(target)); // link cycle
645        }
646        seen.push(target.to_owned());
647        let mut dir = root;
648        let mut components = target.split('/').filter(|c| !c.is_empty());
649        loop {
650            let Some(component) = components.next() else {
651                return Err(bad(target));
652            };
653            match dir.children.get(component) {
654                Some(StreamNode::File { inode_number }) if components.next().is_none() => {
655                    return Ok(*inode_number);
656                }
657                // The last component is another hardlink: share its
658                // target's inode (link-to-link).
659                Some(StreamNode::DeferredLink { target: t }) if components.next().is_none() => {
660                    return chase(root, t, seen);
661                }
662                Some(StreamNode::Dir(child)) => dir = child,
663                _ => return Err(bad(target)),
664            }
665        }
666    }
667
668    fn replace(
669        dir: &mut StreamDir,
670        resolved: &mut std::vec::IntoIter<u64>,
671        ctx: &mut WriteContext,
672    ) {
673        for node in dir.children.values_mut() {
674            match node {
675                StreamNode::DeferredLink { .. } => {
676                    let inode_number = resolved.next().expect("collected matches replaced");
677                    *ctx.nlink_counts.entry(inode_number).or_insert(1) += 1;
678                    *node = StreamNode::File { inode_number };
679                }
680                StreamNode::Dir(child) => replace(child, resolved, ctx),
681                _ => {}
682            }
683        }
684    }
685
686    let mut targets = Vec::new();
687    collect(root, &mut targets);
688    if targets.is_empty() {
689        return Ok(());
690    }
691    let mut resolved = Vec::with_capacity(targets.len());
692    for target in &targets {
693        resolved.push(chase(root, target, &mut Vec::new())?);
694    }
695    replace(root, &mut resolved.into_iter(), ctx);
696    Ok(())
697}
698
699/// Validate and convert caller-supplied xattrs to wire form:
700/// namespace 0, 64 KiB total cap (metadata DoS guard), and the pax
701/// transport's hard limits — keys and values must be NUL-free and
702/// newline-free (a pax record is a length-prefixed text line).
703/// Returns an error naming the offending attribute.
704fn to_wire_xattrs(
705    raw: &[(String, Vec<u8>)],
706) -> Result<Vec<limnifs_core::inode::XAttr>, WriteError> {
707    const TOTAL_CAP: usize = 64 * 1024;
708    let mut out = Vec::with_capacity(raw.len());
709    let mut total = 0usize;
710    for (key, value) in raw {
711        if key.contains('\0') || key.contains('\n') || value.contains(&0) || value.contains(&b'\n')
712        {
713            return Err(WriteError::Io(std::io::Error::other(format!(
714                "xattr {key:?} carries NUL or newline bytes the pax record format cannot represent"
715            ))));
716        }
717        total += key.len() + value.len();
718        if total > TOTAL_CAP {
719            break;
720        }
721        out.push(limnifs_core::inode::XAttr {
722            namespace: 0,
723            key: key.clone(),
724            value: value.clone(),
725        });
726    }
727    Ok(out)
728}
729
730fn bad_name(name: &str) -> WriteError {
731    WriteError::Io(std::io::Error::other(format!(
732        "invalid stream entry name {name:?}: must be a non-empty relative path without '.', '..', '\\', or NUL components"
733    )))
734}
735
736fn name_conflict(name: &str) -> WriteError {
737    WriteError::Io(std::io::Error::other(format!(
738        "stream entry conflict: {name:?} already exists with a different type"
739    )))
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    fn writer() -> StreamWriter<'static> {
747        // Leak is test-only and the config has no drop significance.
748        let config: &'static WriteConfig = Box::leak(Box::new(WriteConfig::default_v0_1()));
749        StreamWriter::new(config).expect("default config is valid")
750    }
751
752    fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
753        let mut state = seed;
754        let mut out = Vec::with_capacity(count);
755        for _ in 0..count {
756            state = state
757                .wrapping_mul(6_364_136_223_846_793_005)
758                .wrapping_add(1_442_695_040_888_963_407);
759            out.push(u8::try_from(state >> 56).expect("fits u8"));
760        }
761        out
762    }
763
764    fn add_all(w: &mut StreamWriter<'_>) {
765        w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
766            .expect("dir");
767        w.add_file(
768            "docs/readme.txt",
769            EntryMeta::new(1_000_000_000, 0o644),
770            &[],
771            &mut b"hello stream writer\n".as_slice(),
772        )
773        .expect("file 1");
774        let big = pseudo_random_bytes(9, 600 * 1024);
775        w.add_file(
776            "data/big.bin",
777            EntryMeta::new(2_000_000_000, 0o755),
778            &[],
779            &mut big.as_slice(),
780        )
781        .expect("file 2");
782        w.add_symlink(
783            "latest",
784            "docs/readme.txt",
785            EntryMeta::new(3_000_000_000, 0o777),
786        )
787        .expect("symlink");
788    }
789
790    #[test]
791    fn same_entry_sequence_packs_identically() {
792        let a = {
793            let mut w = writer();
794            add_all(&mut w);
795            w.finish().expect("finish a").bytes
796        };
797        let b = {
798            let mut w = writer();
799            add_all(&mut w);
800            w.finish().expect("finish b").bytes
801        };
802        assert_eq!(a, b);
803    }
804
805    #[test]
806    fn empty_stream_writes_root_only() {
807        let artifact = writer().finish().expect("finish");
808        assert_eq!(artifact.dir_count, 1);
809        assert_eq!(artifact.file_count, 0);
810        assert!(artifact.slabs.is_empty());
811    }
812
813    /// v0.3.44: the stream seam carries ownership, not just
814    /// mtime/perms — EntryMeta lands verbatim on the emitted inode
815    /// for every entry kind (file, staged file, dir, symlink).
816    #[test]
817    fn entry_meta_lands_on_inodes() {
818        let artifact = {
819            let mut w = writer();
820            w.add_dir("d", EntryMeta::new(1_111_111_111_111, 0o755).owner(12, 34))
821                .expect("dir");
822            w.add_file(
823                "d/f.txt",
824                EntryMeta::new(1_234_567_891_234, 0o640).owner(1000, 20),
825                &[],
826                &mut b"body\n".as_slice(),
827            )
828            .expect("file");
829            w.stage_file(
830                "d/s.bin",
831                EntryMeta::new(2_222_222_222_222, 0o600).owner(1001, 21),
832                &[],
833                b"staged",
834            )
835            .expect("staged");
836            w.add_symlink(
837                "d/l",
838                "d/f.txt",
839                EntryMeta::new(3_333_333_333_333, 0o777).owner(1002, 22),
840            )
841            .expect("symlink");
842            w.finish().expect("finish")
843        };
844        use limnifs_core::{parse_metadata_blob, parse_metadata_reference, ManifestCursor};
845        let mut cursor = ManifestCursor::new(&artifact.bytes);
846        limnifs_core::parse_manifest_header(&mut cursor).expect("header");
847        limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
848        let meta_ref = parse_metadata_reference(&mut cursor).expect("meta");
849        let inline = meta_ref.inline_metadata.as_ref().expect("inline");
850        let mut blob_cursor = ManifestCursor::new(inline);
851        let blob = parse_metadata_blob(&mut blob_cursor).expect("blob");
852
853        let file = blob
854            .inodes
855            .iter()
856            .find(|i| i.uid == 1000 && i.gid == 20)
857            .expect("owned file inode");
858        assert_eq!(file.mtime_ns, 1_234_567_891_234);
859        assert_eq!(file.mode & 0o7777, 0o640);
860        assert!(blob
861            .inodes
862            .iter()
863            .any(|i| i.uid == 1001 && i.gid == 21 && i.mtime_ns == 2_222_222_222_222));
864        assert!(blob
865            .inodes
866            .iter()
867            .any(|i| i.uid == 1002 && i.gid == 22 && i.mtime_ns == 3_333_333_333_333));
868        assert!(blob.inodes.iter().any(|i| i.is_directory()
869            && i.uid == 12
870            && i.gid == 34
871            && i.mtime_ns == 1_111_111_111_111));
872    }
873
874    #[test]
875    fn small_files_inline_and_big_files_slab() {
876        let artifact = {
877            let mut w = writer();
878            add_all(&mut w);
879            w.finish().expect("finish")
880        };
881        assert_eq!(artifact.file_count, 2);
882        assert_eq!(artifact.dir_count, 3); // root + docs + data (implicit)
883        assert_eq!(artifact.slabs.len(), 1);
884    }
885
886    #[test]
887    fn staged_path_is_byte_identical_to_serial() {
888        let big_a = pseudo_random_bytes(31, 600 * 1024);
889        let big_b = pseudo_random_bytes(32, 900 * 1024);
890        let staged = {
891            let mut w = writer();
892            w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
893                .expect("dir");
894            w.add_file(
895                "tiny.txt",
896                EntryMeta::new(1, 0o644),
897                &[],
898                &mut b"small inline entry\n".as_slice(),
899            )
900            .expect("immediate file");
901            w.stage_file("docs/a.bin", EntryMeta::new(2, 0o644), &[], &big_a)
902                .expect("staged a");
903            w.stage_file("docs/b.bin", EntryMeta::new(3, 0o755), &[], &big_b)
904                .expect("staged b");
905            w.stage_file(
906                "docs/tiny2.txt",
907                EntryMeta::new(4, 0o600),
908                &[],
909                b"also inline\n",
910            )
911            .expect("staged tiny");
912            w.finish().expect("finish staged").bytes
913        };
914        let serial = {
915            let mut w = writer();
916            w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
917                .expect("dir");
918            w.add_file(
919                "tiny.txt",
920                EntryMeta::new(1, 0o644),
921                &[],
922                &mut b"small inline entry\n".as_slice(),
923            )
924            .expect("immediate file");
925            w.add_file(
926                "docs/a.bin",
927                EntryMeta::new(2, 0o644),
928                &[],
929                &mut big_a.as_slice(),
930            )
931            .expect("serial a");
932            w.add_file(
933                "docs/b.bin",
934                EntryMeta::new(3, 0o755),
935                &[],
936                &mut big_b.as_slice(),
937            )
938            .expect("serial b");
939            w.add_file(
940                "docs/tiny2.txt",
941                EntryMeta::new(4, 0o600),
942                &[],
943                &mut b"also inline\n".as_slice(),
944            )
945            .expect("serial tiny");
946            w.finish().expect("finish serial").bytes
947        };
948        assert_eq!(staged, serial, "staged flush must equal the serial path");
949    }
950
951    #[test]
952    fn staged_detects_conflicts_and_bad_names() {
953        let mut w = writer();
954        w.stage_file("a.txt", EntryMeta::new(0, 0o644), &[], b"x")
955            .expect("stage");
956        assert!(w
957            .stage_file("a.txt", EntryMeta::new(0, 0o644), &[], b"y")
958            .is_err());
959        assert!(w
960            .stage_file("", EntryMeta::new(0, 0o644), &[], b"y")
961            .is_err());
962        assert!(w
963            .stage_file("/abs", EntryMeta::new(0, 0o644), &[], b"y")
964            .is_err());
965        assert!(w
966            .stage_file("a.txt/child", EntryMeta::new(0, 0o644), &[], b"y")
967            .is_err());
968    }
969
970    #[test]
971    fn rejects_bad_and_conflicting_names() {
972        let mut w = writer();
973        assert!(w
974            .add_file("", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
975            .is_err());
976        assert!(w
977            .add_file("/abs", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
978            .is_err());
979        assert!(w
980            .add_file("a/../b", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
981            .is_err());
982        assert!(w
983            .add_file("ok.txt", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
984            .is_ok());
985        // Same leaf again, even with identical type: conflict.
986        assert!(w
987            .add_file("ok.txt", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
988            .is_err());
989        // File where a directory must pass through.
990        assert!(w
991            .add_file(
992                "ok.txt/child",
993                EntryMeta::new(0, 0o644),
994                &[],
995                &mut [].as_slice()
996            )
997            .is_err());
998        // Symlink over a file.
999        assert!(w
1000            .add_symlink("ok.txt", "x", EntryMeta::new(0, 0o777))
1001            .is_err());
1002        // Windows-separator and NUL names escape extraction on
1003        // Windows binaries (or crash the io layer); reject at pack.
1004        assert!(w
1005            .add_file(r"a\b", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
1006            .is_err());
1007        assert!(w
1008            .add_dir(r"\\server\share", EntryMeta::new(0, 0o755))
1009            .is_err());
1010        assert!(w
1011            .add_file("nul\0x", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
1012            .is_err());
1013    }
1014}