Skip to main content

composefs_splitdirfdstream/
lib.rs

1//! A data format and IPC protocol for sending a binary stream across local
2//! processes via file descriptor passing (DBus, varlink, etc.).
3//!
4//! Designed for sending tar archives of container image layers that are unpacked
5//! into a storage system such as composefs or docker/podman `overlay` storage.
6//! More generally it is a mechanism for reassembling any byte stream from a mix
7//! of inline bytes and whole-file references, useful whenever the bulk of a
8//! stream already lives as files in a content store and copying that bulk inline
9//! would be wasteful.
10//!
11//! External content is identified by `(dirfd_index, filename)` pairs so the
12//! receiving side can `openat2` the files itself given a small out-of-band array
13//! of directory file descriptors. This avoids passing one open fd per external
14//! chunk (which would not scale to streams referencing thousands of files) and
15//! suits reconstructing a stream from a content store laid out on disk.
16//!
17//! # Format
18//!
19//! A splitdirfdstream is a sequence of chunks with no header or footer. Combined
20//! with an out-of-band, ordered array of directory file descriptors `dirfds[0..D]`,
21//! it reconstructs a byte stream by concatenating each chunk's contribution:
22//!
23//! - **Metadata chunk** — raw stream metadata (tar header/padding) carried verbatim.
24//! - **InlineData chunk** — file content transported inline (for non-world-readable
25//!   files the producer read through a privileged fd).
26//! - **FileBackedData chunk** — `content_length` bytes of a file, resolved via
27//!   `openat2(dirfds[dirfd_index], filename, RESOLVE_BENEATH)` and read from offset 0.
28//!
29//! All integers are little-endian. Each chunk begins with a single type byte:
30//!
31//! | Type byte | Chunk          | Remaining header                                                  | Body                         |
32//! |-----------|----------------|-------------------------------------------------------------------|------------------------------|
33//! | `0x00`    | Metadata       | `u32 LE` — body length                                            | `length` bytes               |
34//! | `0x01`    | InlineData     | `u32 LE` — body length                                            | `length` bytes               |
35//! | `0x02`    | FileBackedData | `u64 LE` content_length, `u32 LE` dirfd_index, `u32 LE` name_len | `name_len` bytes of filename |
36//!
37//! Any other type byte is a hard error ([`Error::UnknownChunkType`]).
38//!
39//! There is no in-band end-of-stream sentinel: the stream ends at clean EOF at
40//! the start of a type byte. A partial read anywhere inside a chunk is a
41//! truncation error ([`Error::Truncated`]). Because the format carries no length
42//! or checksum of the whole stream, callers that need end-to-end integrity should
43//! verify the reconstructed bytes against an expected size and digest out of band.
44//!
45//! # Chunk layouts
46//!
47//! ### Metadata
48//!
49//! ```text
50//! +--------+---------------+----------------------------+
51//! | 0x00   | length: u32LE | data: `length` raw bytes   |
52//! +--------+---------------+----------------------------+
53//! ```
54//!
55//! `data` (`length` bytes) is written verbatim to output (tar headers, padding,
56//! etc.). Empty writes are silently dropped by the writer; a zero-length Metadata
57//! chunk is never encoded. `length` is bounded by [`MAX_INLINE_CHUNK_SIZE`] (256 MiB).
58//!
59//! ### InlineData
60//!
61//! ```text
62//! +--------+---------------+----------------------------+
63//! | 0x01   | length: u32LE | data: `length` raw bytes   |
64//! +--------+---------------+----------------------------+
65//! ```
66//!
67//! `length` is both the byte count of the data that follows and the logical file
68//! size the consumer uses for its inline-vs-object storage decision. Unlike
69//! FileBackedData, no directory fd is involved; the producer has already read the
70//! content through its own privileged fd.
71//!
72//! A zero-length InlineData **is** written and round-trips correctly — a zero-byte
73//! non-world-readable file must still be transported. `length` is bounded by
74//! [`MAX_INLINE_CHUNK_SIZE`] (256 MiB), since the data is fully buffered in memory
75//! during transport.
76//!
77//! ### FileBackedData
78//!
79//! ```text
80//! +--------+---------------------+--------------------+-----------------+------------------------------+
81//! | 0x02   | content_len: u64LE  | dirfd_index: u32LE | name_len: u32LE | filename: name_len raw bytes |
82//! +--------+---------------------+--------------------+-----------------+------------------------------+
83//! ```
84//!
85//! The consumer reads exactly `content_len` bytes starting at offset 0 of the
86//! opened file. The explicit `content_len` makes the stream self-framing — entry
87//! boundaries never depend on trusting the backing file's size, which closes a
88//! TOCTOU window if the underlying store is mutated mid-read.
89//!
90//! A FileBackedData chunk always starts at offset 0: it references a whole file,
91//! not a byte range. This is deliberate — it lets every external reference reflink
92//! cleanly (`FICLONE`) when materialized on a CoW filesystem. There is no range
93//! variant.
94//!
95//! `filename` is a path relative to `dirfds[dirfd_index]`. It may contain `/` to
96//! traverse subdirectories within that root; it is not NUL-terminated and must not
97//! contain NUL.
98//!
99//! # Limits
100//!
101//! | Constant | Value | Purpose |
102//! |----------|-------|---------|
103//! | [`MAX_INLINE_CHUNK_SIZE`] | 256 MiB | Bounds memory for a single Metadata or InlineData chunk body. |
104//! | [`MAX_FILENAME_LEN`] | 4096 bytes | Bounds the FileBackedData filename length. |
105//!
106//! The reader rejects an out-of-range `dirfd_index`, and Metadata or InlineData
107//! chunks whose `length` exceeds `MAX_INLINE_CHUNK_SIZE`.
108//!
109//! # Safety
110//!
111//! The consuming side should always use `openat2(RESOLVE_BENEATH)` or equivalent.
112//! This crate uses `rustix::fs::openat2` with
113//! `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS`, falling back
114//! to `openat(O_NOFOLLOW)` on kernels older than 5.6. The [`validate_filename`]
115//! function rejects `..` components, absolute paths, and embedded NUL bytes before
116//! any syscall is made.
117//!
118//! The reader performs only *structural* validation (framing, limits); it does
119//! **not** validate filename *content*. Callers that consume
120//! [`Chunk::FileBackedData`] `.filename` directly must call [`validate_filename`]
121//! (or use [`open_beneath`] / [`reconstruct`], which do).
122//!
123//! # Examples
124//!
125//! Write a stream, then inspect its chunk structure:
126//!
127//! ```
128//! use composefs_splitdirfdstream::{SplitdirfdstreamWriter, SplitdirfdstreamReader, Chunk};
129//!
130//! // Write a stream: inline glue bytes plus a reference to dirfds[0]/data/blob.
131//! let mut buffer = Vec::new();
132//! let mut writer = SplitdirfdstreamWriter::new(&mut buffer);
133//! writer.write_metadata(b"tar header bytes").unwrap();
134//! writer.write_file_backed_data(0, 5, b"data/blob").unwrap(); // 5 bytes from dirfds[0]/data/blob
135//! writer.write_metadata(b"tar padding").unwrap();
136//! writer.finish().unwrap();
137//!
138//! // Inspect the chunk structure.
139//! let mut reader = SplitdirfdstreamReader::new(buffer.as_slice());
140//! while let Some(chunk) = reader.next_chunk().unwrap() {
141//!     match chunk {
142//!         Chunk::Metadata(data) => { /* tar header/padding bytes */ }
143//!         Chunk::InlineData(data) => { /* inline file content (non-world-readable files) */ }
144//!         Chunk::FileBackedData { dirfd_index, length, filename } => { /* (dirfds[i], name, len) */ }
145//!     }
146//! }
147//! ```
148//!
149//! To reconstruct the full byte stream, supply the directory fds to
150//! [`reconstruct`], which resolves and splices each external chunk for you:
151//!
152//! ```no_run
153//! use std::os::fd::BorrowedFd;
154//! # fn demo(stream: &[u8], dir: BorrowedFd<'_>, out: &mut Vec<u8>) {
155//! let dirfds = [dir];
156//! let total = composefs_splitdirfdstream::reconstruct(stream, &dirfds, out).unwrap();
157//! # let _ = total;
158//! # }
159//! ```
160//!
161//! # API surface
162//!
163//! | Item | Role |
164//! |------|------|
165//! | [`SplitdirfdstreamWriter`] | Encode inline + external chunks into the wire format. |
166//! | [`SplitdirfdstreamReader`] | Iterate a stream as borrowed [`Chunk`]s. |
167//! | [`Chunk`] | `Metadata(&[u8])`, `InlineData(&[u8])`, or `FileBackedData { dirfd_index, length, filename }`. |
168//! | [`reconstruct`] | Reconstruct the full byte stream given the directory fds. |
169//! | [`open_beneath`] | Safely open one external file beneath a directory fd. |
170//! | [`validate_filename`] | The path-safety predicate (reused by the writer/consumer). |
171//!
172//! # See also
173//!
174//! This crate is only the stream format. A higher-level control channel —
175//! opening a source, negotiating capabilities, and handing over the stream fd
176//! plus directory fds over a socket via `SCM_RIGHTS` — is layered on top
177//! elsewhere (e.g. the `composefs-storage` layer-transfer service); it carries
178//! structured metadata only, with all binary content flowing through this format
179//! and the directory fds.
180
181// This is a library: emit diagnostics via the `log` crate (or return them),
182// never by writing to the process's stdout/stderr. Genuinely-intentional
183// exceptions carry a local `#[allow]` with justification. Test code is exempt.
184#![cfg_attr(not(test), deny(clippy::print_stdout, clippy::print_stderr))]
185
186use std::ffi::CString;
187use std::io::{Read, Write};
188use std::os::fd::{BorrowedFd, OwnedFd};
189
190use rustix::fs::{Mode, OFlags, ResolveFlags};
191use rustix::io::Errno;
192
193pub mod transport;
194#[cfg(feature = "tokio")]
195pub use transport::spawn_self_reaping_producer;
196pub use transport::{
197    FdLimitError, LayerFdLayout, MAX_FDS_PER_FRAME, build_layer_fd_layout, open_devnull,
198    seed_from_id, split_fds_into_frames,
199};
200
201/// Maximum size for an inline chunk (256 MiB).
202///
203/// This limit prevents denial-of-service attacks where a malicious stream
204/// could specify an extremely large inline chunk size, causing unbounded
205/// memory allocation.
206pub const MAX_INLINE_CHUNK_SIZE: usize = 256 * 1024 * 1024;
207
208/// Maximum length of an external filename in bytes.
209///
210/// Filenames longer than this are rejected by [`validate_filename`] and
211/// by the reader before any buffer is resized.
212pub const MAX_FILENAME_LEN: usize = 4096;
213
214/// Errors that can occur while reading or writing a splitdirfdstream.
215#[derive(Debug, thiserror::Error)]
216#[non_exhaustive]
217pub enum Error {
218    /// An underlying I/O error.
219    #[error(transparent)]
220    Io(#[from] std::io::Error),
221
222    /// The stream ended in the middle of a chunk.
223    #[error("truncated stream: expected {expected} more bytes in chunk")]
224    Truncated {
225        /// How many bytes were still expected when EOF was encountered.
226        ///
227        /// Semantics depend on *where* truncation occurred:
228        ///
229        /// - **Type byte**: `1` if EOF arrived after 0 bytes (but that is
230        ///   returned as `Ok(None)`; this only fires for 0 < n < expected).
231        /// - **Inline body or FileBackedData header/name**: the *full* declared
232        ///   size (i.e. the number passed to `read_exact`), because `read_exact`
233        ///   does not report how many bytes it successfully consumed before EOF.
234        expected: u64,
235    },
236
237    /// An inline chunk's size field exceeds [`MAX_INLINE_CHUNK_SIZE`].
238    #[error("inline chunk size {size} exceeds maximum {max}")]
239    InlineTooLarge {
240        /// The size read from the stream.
241        size: usize,
242        /// The maximum allowed size.
243        max: usize,
244    },
245
246    /// An unrecognised chunk type byte was encountered.
247    #[error("unknown chunk type byte 0x{0:02x}")]
248    UnknownChunkType(u8),
249
250    /// A filename exceeds [`MAX_FILENAME_LEN`] bytes.
251    #[error("filename length {len} exceeds maximum {max}")]
252    FilenameTooLong {
253        /// The length of the filename.
254        len: usize,
255        /// The maximum allowed length.
256        max: usize,
257    },
258
259    /// A filename failed validation.
260    #[error("invalid filename: {reason}")]
261    InvalidFilename {
262        /// Human-readable description of why the filename is invalid.
263        reason: &'static str,
264    },
265
266    /// A dirfd index in an external chunk was out of range.
267    #[error("dirfd index {index} out of range (have {count} dirfds)")]
268    DirfdIndexOutOfRange {
269        /// The index that was out of range.
270        index: u32,
271        /// The number of dirfds available.
272        count: usize,
273    },
274
275    /// An external file was shorter than declared in the stream.
276    #[error("external file shorter than declared length {declared} (got {actual})")]
277    ExternalTooShort {
278        /// The length declared in the stream.
279        declared: u64,
280        /// The number of bytes actually read before EOF.
281        actual: u64,
282    },
283}
284
285impl From<Errno> for Error {
286    fn from(e: Errno) -> Self {
287        Error::Io(e.into())
288    }
289}
290
291/// Convenience alias for `Result<T, Error>`.
292pub type Result<T> = std::result::Result<T, Error>;
293
294/// A chunk decoded from a splitdirfdstream.
295///
296/// Chunks carry either stream metadata (raw tar header/padding bytes),
297/// inline-transported file content (non-world-readable files the producer
298/// read through a privileged fd), or references to external files identified
299/// by a directory fd index and a relative filename.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub enum Chunk<'a> {
302    /// Stream metadata: raw tar header or padding bytes embedded directly in
303    /// the stream. Consumers write these verbatim into the output.
304    Metadata(&'a [u8]),
305
306    /// Inline-transported file content. The producer read these bytes through
307    /// a privileged fd; the consumer decides whether to store them inline
308    /// (≤ splitstream threshold) or as an external object based on `data.len()`.
309    InlineData(&'a [u8]),
310
311    /// Reference to an external file relative to one of the caller-supplied
312    /// directory file descriptors.
313    ///
314    /// The reader does **not** validate `filename` content — callers that
315    /// consume `filename` directly **must** call [`validate_filename`] or use
316    /// [`open_beneath`] / [`reconstruct`], which call it internally.
317    FileBackedData {
318        /// Index into the `dirfds` array supplied to [`reconstruct`].
319        dirfd_index: u32,
320        /// Number of bytes to read from the file, starting at offset 0.
321        length: u64,
322        /// Relative filename within `dirfds[dirfd_index]`.
323        filename: &'a [u8],
324    },
325}
326
327/// Writer for building a splitdirfdstream.
328///
329/// Encodes inline data and dirfd-relative file references into the
330/// splitdirfdstream binary format.
331///
332/// # Example
333///
334/// ```
335/// use composefs_splitdirfdstream::SplitdirfdstreamWriter;
336///
337/// let mut buffer = Vec::new();
338/// let mut writer = SplitdirfdstreamWriter::new(&mut buffer);
339/// writer.write_metadata(b"hello").unwrap();
340/// writer.write_file_backed_data(0, 42, b"objects/abc123").unwrap();
341/// let _buf = writer.finish().unwrap();
342/// ```
343#[derive(Debug)]
344pub struct SplitdirfdstreamWriter<W> {
345    writer: W,
346}
347
348impl<W: Write> SplitdirfdstreamWriter<W> {
349    /// Create a new writer wrapping `writer`.
350    pub fn new(writer: W) -> Self {
351        Self { writer }
352    }
353
354    /// Write an inline chunk containing `data`.
355    ///
356    /// Empty slices are silently ignored (no bytes are written).
357    ///
358    /// # Errors
359    ///
360    /// Returns [`Error::InlineTooLarge`] if `data.len()` exceeds
361    /// [`MAX_INLINE_CHUNK_SIZE`], or propagates I/O errors from the
362    /// underlying writer.
363    pub fn write_metadata(&mut self, data: &[u8]) -> Result<()> {
364        if data.is_empty() {
365            return Ok(());
366        }
367        if data.len() > MAX_INLINE_CHUNK_SIZE {
368            return Err(Error::InlineTooLarge {
369                size: data.len(),
370                max: MAX_INLINE_CHUNK_SIZE,
371            });
372        }
373        self.writer.write_all(&[0x00u8])?;
374        self.writer.write_all(&(data.len() as u32).to_le_bytes())?;
375        self.writer.write_all(data)?;
376        Ok(())
377    }
378
379    /// Write an [`InlineData`](Chunk::InlineData) chunk carrying `data` bytes
380    /// of file content transported inline.
381    ///
382    /// Unlike [`write_metadata`](Self::write_metadata), a zero-length slice IS
383    /// written (a zero-byte non-world-readable file must still round-trip).
384    ///
385    /// # Errors
386    ///
387    /// Returns [`Error::InlineTooLarge`] if `data.len()` exceeds
388    /// [`MAX_INLINE_CHUNK_SIZE`], or propagates I/O errors from the underlying
389    /// writer.
390    pub fn write_inline_data(&mut self, data: &[u8]) -> Result<()> {
391        if data.len() > MAX_INLINE_CHUNK_SIZE {
392            return Err(Error::InlineTooLarge {
393                size: data.len(),
394                max: MAX_INLINE_CHUNK_SIZE,
395            });
396        }
397        self.writer.write_all(&[0x01u8])?;
398        self.writer.write_all(&(data.len() as u32).to_le_bytes())?;
399        self.writer.write_all(data)?;
400        Ok(())
401    }
402
403    /// Write a [`FileBackedData`](Chunk::FileBackedData) chunk referencing a
404    /// file by dirfd index and relative filename.
405    ///
406    /// The consumer will open `filename` beneath `dirfds[dirfd_index]` and
407    /// read exactly `length` bytes from offset 0.
408    ///
409    /// `filename` is validated by [`validate_filename`] before writing.
410    ///
411    /// # Errors
412    ///
413    /// Returns any [`Error`] produced by [`validate_filename`], or propagates
414    /// I/O errors from the underlying writer.
415    pub fn write_file_backed_data(
416        &mut self,
417        dirfd_index: u32,
418        length: u64,
419        filename: &[u8],
420    ) -> Result<()> {
421        validate_filename(filename)?;
422        self.writer.write_all(&[0x02u8])?;
423        self.writer.write_all(&length.to_le_bytes())?;
424        self.writer.write_all(&dirfd_index.to_le_bytes())?;
425        self.writer
426            .write_all(&(filename.len() as u32).to_le_bytes())?;
427        self.writer.write_all(filename)?;
428        Ok(())
429    }
430
431    /// Consume the writer and return the underlying `Write` impl.
432    pub fn finish(self) -> Result<W> {
433        Ok(self.writer)
434    }
435}
436
437/// Reader for parsing a splitdirfdstream.
438///
439/// Yields [`Chunk`] values by parsing the binary format. The internal buffer
440/// is reused across calls so that only one chunk's data is live at a time.
441///
442/// # Example
443///
444/// ```
445/// use composefs_splitdirfdstream::{SplitdirfdstreamReader, Chunk};
446///
447/// // Manually constructed stream: Metadata "hello"
448/// // Format: [0x00][5u32 LE][b"hello"]
449/// let mut data = Vec::new();
450/// data.push(0x00u8);
451/// data.extend_from_slice(&5u32.to_le_bytes());
452/// data.extend_from_slice(b"hello");
453///
454/// let mut reader = SplitdirfdstreamReader::new(data.as_slice());
455/// assert_eq!(reader.next_chunk().unwrap(), Some(Chunk::Metadata(b"hello")));
456/// assert_eq!(reader.next_chunk().unwrap(), None);
457/// ```
458#[derive(Debug)]
459pub struct SplitdirfdstreamReader<R> {
460    reader: R,
461    /// Internal buffer reused across [`next_chunk`](SplitdirfdstreamReader::next_chunk) calls.
462    buffer: Vec<u8>,
463}
464
465impl<R: Read> SplitdirfdstreamReader<R> {
466    /// Create a new reader wrapping `reader`.
467    pub fn new(reader: R) -> Self {
468        Self {
469            reader,
470            buffer: Vec::new(),
471        }
472    }
473
474    /// Consume this reader, returning the underlying `Read` impl.
475    pub fn into_inner(self) -> R {
476        self.reader
477    }
478
479    /// Return the next chunk from the stream, or `None` at a clean EOF.
480    ///
481    /// A clean EOF is one where zero bytes have been consumed from the current
482    /// type byte. Any partial read (0 < n < expected) is [`Error::Truncated`].
483    ///
484    /// The returned [`Chunk::FileBackedData`] contains the raw `filename` bytes
485    /// without any validation — callers that use `filename` directly **must**
486    /// call [`validate_filename`] themselves, or use [`reconstruct`] which does
487    /// it internally via [`open_beneath`].
488    ///
489    /// # Errors
490    ///
491    /// - [`Error::Truncated`] — stream ended mid-chunk
492    /// - [`Error::InlineTooLarge`] — Metadata or InlineData body size exceeds [`MAX_INLINE_CHUNK_SIZE`]
493    /// - [`Error::FilenameTooLong`] — filename length exceeds [`MAX_FILENAME_LEN`]
494    /// - [`Error::UnknownChunkType`] — unrecognised type byte
495    /// - [`Error::Io`] — underlying I/O error
496    pub fn next_chunk(&mut self) -> Result<Option<Chunk<'_>>> {
497        // Step 1: read the 1-byte type, distinguishing clean EOF from truncation.
498        let mut type_byte = [0u8; 1];
499        let mut got = 0usize;
500        loop {
501            match self.reader.read(&mut type_byte[got..]) {
502                Ok(0) => {
503                    if got == 0 {
504                        return Ok(None); // clean EOF at chunk boundary
505                    }
506                    return Err(Error::Truncated { expected: 1 });
507                }
508                Ok(n) => {
509                    got += n;
510                    if got == 1 {
511                        break;
512                    }
513                }
514                Err(e) => return Err(Error::Io(e)),
515            }
516        }
517
518        match type_byte[0] {
519            0x00 | 0x01 => {
520                // Metadata (0x00) or InlineData (0x01): read 4-byte u32 LE body length.
521                let mut len_bytes = [0u8; 4];
522                self.reader.read_exact(&mut len_bytes).map_err(|e| {
523                    if e.kind() == std::io::ErrorKind::UnexpectedEof {
524                        Error::Truncated { expected: 4 }
525                    } else {
526                        Error::Io(e)
527                    }
528                })?;
529                let length = u32::from_le_bytes(len_bytes) as usize;
530                if length > MAX_INLINE_CHUNK_SIZE {
531                    return Err(Error::InlineTooLarge {
532                        size: length,
533                        max: MAX_INLINE_CHUNK_SIZE,
534                    });
535                }
536                self.buffer.resize(length, 0);
537                self.reader.read_exact(&mut self.buffer).map_err(|e| {
538                    if e.kind() == std::io::ErrorKind::UnexpectedEof {
539                        Error::Truncated {
540                            expected: length as u64,
541                        }
542                    } else {
543                        Error::Io(e)
544                    }
545                })?;
546                if type_byte[0] == 0x00 {
547                    Ok(Some(Chunk::Metadata(&self.buffer)))
548                } else {
549                    Ok(Some(Chunk::InlineData(&self.buffer)))
550                }
551            }
552            0x02 => {
553                // FileBackedData: [u64 LE content_length][u32 LE dirfd_index][u32 LE name_len][name bytes]
554                let mut header = [0u8; 16];
555                self.reader.read_exact(&mut header).map_err(|e| {
556                    if e.kind() == std::io::ErrorKind::UnexpectedEof {
557                        Error::Truncated { expected: 16 }
558                    } else {
559                        Error::Io(e)
560                    }
561                })?;
562                let length = u64::from_le_bytes(header[0..8].try_into().unwrap());
563                let dirfd_index = u32::from_le_bytes(header[8..12].try_into().unwrap());
564                let name_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
565                if name_len > MAX_FILENAME_LEN {
566                    return Err(Error::FilenameTooLong {
567                        len: name_len,
568                        max: MAX_FILENAME_LEN,
569                    });
570                }
571                self.buffer.resize(name_len, 0);
572                self.reader.read_exact(&mut self.buffer).map_err(|e| {
573                    if e.kind() == std::io::ErrorKind::UnexpectedEof {
574                        Error::Truncated {
575                            expected: name_len as u64,
576                        }
577                    } else {
578                        Error::Io(e)
579                    }
580                })?;
581                Ok(Some(Chunk::FileBackedData {
582                    dirfd_index,
583                    length,
584                    filename: &self.buffer,
585                }))
586            }
587            other => Err(Error::UnknownChunkType(other)),
588        }
589    }
590}
591
592/// Validate that `filename` is an acceptable external filename.
593///
594/// Checks, in order:
595/// 1. Not empty.
596/// 2. Not longer than [`MAX_FILENAME_LEN`].
597/// 3. No embedded NUL bytes.
598/// 4. Not an absolute path (does not start with `/`).
599/// 5. No `..` path components.
600///
601/// Note that `.` components and empty components (from `//`) are permitted,
602/// as the kernel will handle them safely.
603///
604/// # Errors
605///
606/// Returns [`Error::InvalidFilename`] or [`Error::FilenameTooLong`] on failure.
607pub fn validate_filename(filename: &[u8]) -> Result<()> {
608    if filename.is_empty() {
609        return Err(Error::InvalidFilename {
610            reason: "empty filename",
611        });
612    }
613    if filename.len() > MAX_FILENAME_LEN {
614        return Err(Error::FilenameTooLong {
615            len: filename.len(),
616            max: MAX_FILENAME_LEN,
617        });
618    }
619    if filename.contains(&0u8) {
620        return Err(Error::InvalidFilename {
621            reason: "embedded NUL",
622        });
623    }
624    if filename[0] == b'/' {
625        return Err(Error::InvalidFilename {
626            reason: "absolute path",
627        });
628    }
629    for component in filename.split(|&b| b == b'/') {
630        if component == b".." {
631            return Err(Error::InvalidFilename {
632                reason: "`..` component",
633            });
634        }
635    }
636    Ok(())
637}
638
639/// Open a file at `filename` relative to `dirfd` using safe kernel primitives.
640///
641/// Internally calls `openat2(2)` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS |
642/// RESOLVE_NO_XDEV` when available. On kernels that do not support `openat2`
643/// (`ENOSYS`), falls back to plain `openat(2)`.
644///
645/// # Security note
646///
647/// The `openat2` path prevents directory escapes (`RESOLVE_BENEATH`), blocks
648/// magic-link traversal (`RESOLVE_NO_MAGICLINKS`), prevents crossing filesystem
649/// boundaries (`RESOLVE_NO_XDEV`), and **does** allow symlinks as long as they
650/// resolve within the base directory. A symlink pointing outside the base
651/// directory is still rejected by `RESOLVE_BENEATH`.
652///
653/// The `openat` fallback does not enforce `RESOLVE_BENEATH` (that kernel
654/// feature is unavailable on old kernels), so callers should prefer environments
655/// with kernel ≥ 5.6 (where `openat2` is available) when strict confinement is
656/// required. [`validate_filename`] is still called before any syscall to reject
657/// `..` components and absolute paths.
658///
659/// # Errors
660///
661/// Returns any error from [`validate_filename`], or an [`Error::Io`] wrapping
662/// the kernel error (`EXDEV` for escape attempts, etc.).
663pub fn open_beneath(dirfd: BorrowedFd<'_>, filename: &[u8]) -> Result<OwnedFd> {
664    validate_filename(filename)?;
665
666    // Build a CString: validate_filename rejects embedded NUL so this is infallible.
667    let cname = CString::new(filename).expect("validate_filename guarantees no NUL");
668
669    // Try openat2 first (Linux ≥ 5.6).
670    // RESOLVE_BENEATH  — prevent escaping the base directory via any path tricks.
671    // RESOLVE_NO_MAGICLINKS — block /proc/self/fd-style magic links.
672    // RESOLVE_NO_XDEV  — prevent crossing filesystem mount boundaries.
673    // Symlinks that stay within the base directory are permitted.
674    let oflags = OFlags::RDONLY | OFlags::CLOEXEC;
675    let result = rustix::fs::openat2(
676        dirfd,
677        &cname,
678        oflags,
679        Mode::empty(),
680        ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV,
681    );
682
683    match result {
684        Ok(fd) => Ok(fd),
685        Err(e) if e == Errno::NOSYS => {
686            // Kernel too old for openat2; fall back to plain openat.
687            // validate_filename already rejected `..` and absolute paths.
688            rustix::fs::openat(
689                dirfd,
690                &cname,
691                OFlags::RDONLY | OFlags::CLOEXEC,
692                Mode::empty(),
693            )
694            .map_err(Error::from)
695        }
696        Err(e) => Err(e.into()),
697    }
698}
699
700/// Reconstruct the byte stream encoded in `stream` by combining inline chunks
701/// and external file data, writing all output to `output`.
702///
703/// For each [`Chunk::FileBackedData`], the corresponding file is opened with
704/// [`open_beneath`] using `dirfds[dirfd_index]` as the base directory, and
705/// exactly `length` bytes are read from offset 0 via positional reads
706/// (`pread(2)`), so the same file can be referenced multiple times without
707/// seeking.
708///
709/// Returns the total number of bytes written to `output`.
710///
711/// # Errors
712///
713/// - [`Error::DirfdIndexOutOfRange`] — `dirfd_index` ≥ `dirfds.len()`
714/// - [`Error::ExternalTooShort`] — external file has fewer bytes than declared
715/// - Any error from [`open_beneath`] or from writing to `output`
716pub fn reconstruct<R: Read, W: Write>(
717    stream: R,
718    dirfds: &[BorrowedFd<'_>],
719    output: &mut W,
720) -> Result<u64> {
721    let mut reader = SplitdirfdstreamReader::new(stream);
722    let mut total: u64 = 0;
723    const BUF_SIZE: usize = 128 * 1024;
724
725    while let Some(chunk) = reader.next_chunk()? {
726        match chunk {
727            Chunk::Metadata(data) => {
728                output.write_all(data)?;
729                total += data.len() as u64;
730            }
731            Chunk::InlineData(data) => {
732                output.write_all(data)?;
733                total += data.len() as u64;
734            }
735            Chunk::FileBackedData {
736                dirfd_index,
737                length,
738                filename,
739            } => {
740                let idx = dirfd_index as usize;
741                if idx >= dirfds.len() {
742                    return Err(Error::DirfdIndexOutOfRange {
743                        index: dirfd_index,
744                        count: dirfds.len(),
745                    });
746                }
747                let fd = open_beneath(dirfds[idx], filename)?;
748                // Cap the scratch buffer at BUF_SIZE; never size it from the
749                // attacker-controlled `length` (which can be up to u64::MAX),
750                // which would overflow `as usize` / wrap on 32-bit and panic
751                // under overflow-checks. `to_read` below bounds each read.
752                let mut buf =
753                    vec![0u8; BUF_SIZE.min(usize::try_from(length).unwrap_or(usize::MAX))];
754                let mut remaining = length;
755                let mut offset: u64 = 0;
756                while remaining > 0 {
757                    let to_read = (remaining as usize).min(buf.len());
758                    let n =
759                        rustix::io::pread(&fd, &mut buf[..to_read], offset).map_err(Error::from)?;
760                    if n == 0 {
761                        return Err(Error::ExternalTooShort {
762                            declared: length,
763                            actual: length - remaining,
764                        });
765                    }
766                    output.write_all(&buf[..n])?;
767                    remaining -= n as u64;
768                    offset += n as u64;
769                }
770                total += length;
771            }
772        }
773    }
774
775    Ok(total)
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use std::os::fd::AsFd;
782
783    // -------------------------------------------------------------------------
784    // Helpers
785    // -------------------------------------------------------------------------
786
787    /// Write `chunks` into a buffer and read them back, returning the decoded
788    /// chunks as `(dirfd_index, length, filename, inline_data)` tuples.
789    fn roundtrip_stream(writes: &[WriteCmd<'_>]) -> (Vec<u8>, Vec<DecodedChunk>) {
790        let mut buf = Vec::new();
791        {
792            let mut w = SplitdirfdstreamWriter::new(&mut buf);
793            for cmd in writes {
794                match cmd {
795                    WriteCmd::Metadata(data) => w.write_metadata(data).unwrap(),
796                    WriteCmd::InlineData(data) => w.write_inline_data(data).unwrap(),
797                    WriteCmd::FileBackedData {
798                        dirfd_index,
799                        length,
800                        filename,
801                    } => w
802                        .write_file_backed_data(*dirfd_index, *length, filename)
803                        .unwrap(),
804                }
805            }
806            w.finish().unwrap();
807        }
808        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
809        let mut out = Vec::new();
810        while let Some(chunk) = reader.next_chunk().unwrap() {
811            out.push(DecodedChunk::from(&chunk));
812        }
813        (buf, out)
814    }
815
816    #[derive(Debug)]
817    enum WriteCmd<'a> {
818        Metadata(&'a [u8]),
819        InlineData(&'a [u8]),
820        FileBackedData {
821            dirfd_index: u32,
822            length: u64,
823            filename: &'a [u8],
824        },
825    }
826
827    #[derive(Debug, PartialEq, Eq)]
828    enum DecodedChunk {
829        Metadata(Vec<u8>),
830        InlineData(Vec<u8>),
831        FileBackedData {
832            dirfd_index: u32,
833            length: u64,
834            filename: Vec<u8>,
835        },
836    }
837
838    impl<'a> From<&Chunk<'a>> for DecodedChunk {
839        fn from(c: &Chunk<'a>) -> Self {
840            match c {
841                Chunk::Metadata(d) => DecodedChunk::Metadata(d.to_vec()),
842                Chunk::InlineData(d) => DecodedChunk::InlineData(d.to_vec()),
843                Chunk::FileBackedData {
844                    dirfd_index,
845                    length,
846                    filename,
847                } => DecodedChunk::FileBackedData {
848                    dirfd_index: *dirfd_index,
849                    length: *length,
850                    filename: filename.to_vec(),
851                },
852            }
853        }
854    }
855
856    // -------------------------------------------------------------------------
857    // Basic wire-format tests
858    // -------------------------------------------------------------------------
859
860    #[test]
861    fn empty_stream_returns_none() {
862        let mut reader = SplitdirfdstreamReader::new(b"".as_slice());
863        assert_eq!(reader.next_chunk().unwrap(), None);
864    }
865
866    #[test]
867    fn roundtrip_inline_only() {
868        let (_, chunks) =
869            roundtrip_stream(&[WriteCmd::Metadata(b"hello"), WriteCmd::Metadata(b"world")]);
870        assert_eq!(
871            chunks,
872            vec![
873                DecodedChunk::Metadata(b"hello".to_vec()),
874                DecodedChunk::Metadata(b"world".to_vec()),
875            ]
876        );
877    }
878
879    #[test]
880    fn roundtrip_external_only() {
881        let (_, chunks) = roundtrip_stream(&[
882            WriteCmd::FileBackedData {
883                dirfd_index: 0,
884                length: 100,
885                filename: b"a/b",
886            },
887            WriteCmd::FileBackedData {
888                dirfd_index: 3,
889                length: 0,
890                filename: b"x/y/z",
891            },
892        ]);
893        assert_eq!(
894            chunks,
895            vec![
896                DecodedChunk::FileBackedData {
897                    dirfd_index: 0,
898                    length: 100,
899                    filename: b"a/b".to_vec()
900                },
901                DecodedChunk::FileBackedData {
902                    dirfd_index: 3,
903                    length: 0,
904                    filename: b"x/y/z".to_vec()
905                },
906            ]
907        );
908    }
909
910    #[test]
911    fn roundtrip_mixed_interleaved() {
912        let (_, chunks) = roundtrip_stream(&[
913            WriteCmd::Metadata(b"header"),
914            WriteCmd::FileBackedData {
915                dirfd_index: 0,
916                length: 7,
917                filename: b"blob",
918            },
919            WriteCmd::Metadata(b"middle"),
920            WriteCmd::FileBackedData {
921                dirfd_index: 1,
922                length: 3,
923                filename: b"sub/c",
924            },
925            WriteCmd::Metadata(b"footer"),
926        ]);
927        assert_eq!(chunks.len(), 5);
928        assert_eq!(chunks[0], DecodedChunk::Metadata(b"header".to_vec()));
929        assert_eq!(
930            chunks[1],
931            DecodedChunk::FileBackedData {
932                dirfd_index: 0,
933                length: 7,
934                filename: b"blob".to_vec()
935            }
936        );
937        assert_eq!(chunks[2], DecodedChunk::Metadata(b"middle".to_vec()));
938        assert_eq!(
939            chunks[3],
940            DecodedChunk::FileBackedData {
941                dirfd_index: 1,
942                length: 3,
943                filename: b"sub/c".to_vec()
944            }
945        );
946        assert_eq!(chunks[4], DecodedChunk::Metadata(b"footer".to_vec()));
947    }
948
949    #[test]
950    fn empty_inline_is_no_op() {
951        let (buf, chunks) = roundtrip_stream(&[
952            WriteCmd::Metadata(b""),
953            WriteCmd::Metadata(b"real"),
954            WriteCmd::Metadata(b""),
955        ]);
956        // Only "real" produces a chunk; empties are silently dropped.
957        assert_eq!(chunks, vec![DecodedChunk::Metadata(b"real".to_vec())]);
958        // Buffer: 1-byte type + 4-byte u32 length + 4 bytes data
959        assert_eq!(buf.len(), 9);
960    }
961
962    #[test]
963    fn external_wire_layout() {
964        // Verify the exact byte layout for a known external chunk.
965        // dirfd_index=2, length=9, filename=b"ab"
966        // Format: [0x02][9u64 LE][2u32 LE][2u32 LE][b'a',b'b']
967        let mut buf = Vec::new();
968        SplitdirfdstreamWriter::new(&mut buf)
969            .write_file_backed_data(2, 9, b"ab")
970            .unwrap();
971
972        let expected: Vec<u8> = {
973            let mut v = Vec::new();
974            v.push(0x02u8); // type byte
975            v.extend_from_slice(&9u64.to_le_bytes()); // content_length
976            v.extend_from_slice(&2u32.to_le_bytes()); // dirfd_index
977            v.extend_from_slice(&2u32.to_le_bytes()); // name_len
978            v.extend_from_slice(b"ab"); // filename
979            v
980        };
981        assert_eq!(buf, expected);
982    }
983
984    #[test]
985    fn boundary_inline_sizes() {
986        for &size in &[1usize, 7, 8, 9, 255, 256, 257, 4095, 4096, 4097] {
987            let data: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
988            let (buf, chunks) = roundtrip_stream(&[WriteCmd::Metadata(&data)]);
989
990            // Wire layout: 1-byte type + 4-byte u32 length + `size` bytes
991            assert_eq!(buf.len(), 5 + size, "buf.len() for size={size}");
992            assert_eq!(buf[0], 0x00u8, "type byte for size={size}");
993            let len_field = u32::from_le_bytes(buf[1..5].try_into().unwrap());
994            assert_eq!(len_field, size as u32, "length field for size={size}");
995
996            assert_eq!(
997                chunks,
998                vec![DecodedChunk::Metadata(data)],
999                "data for size={size}"
1000            );
1001        }
1002    }
1003
1004    // -------------------------------------------------------------------------
1005    // Reader limit / boundary error tests (hand-crafted buffers)
1006    // -------------------------------------------------------------------------
1007
1008    #[test]
1009    fn unknown_chunk_type_returns_error() {
1010        // A type byte that is not 0x00, 0x01, or 0x02 must yield UnknownChunkType.
1011        let buf = vec![0x03u8];
1012        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1013        let err = reader.next_chunk().unwrap_err();
1014        assert!(
1015            matches!(err, Error::UnknownChunkType(0x03)),
1016            "expected UnknownChunkType(0x03), got {err:?}"
1017        );
1018    }
1019
1020    #[test]
1021    fn error_unknown_chunk_type() {
1022        // Feeding byte 0x80 must yield UnknownChunkType(0x80).
1023        let buf = vec![0x80u8];
1024        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1025        let err = reader.next_chunk().unwrap_err();
1026        assert!(
1027            matches!(err, Error::UnknownChunkType(0x80)),
1028            "expected UnknownChunkType(0x80), got {err:?}"
1029        );
1030    }
1031
1032    #[test]
1033    fn error_filename_too_long_in_reader() {
1034        // [0x02][0u64 LE][0u32 LE][name_len as u32 LE] with name_len > MAX_FILENAME_LEN
1035        let name_len = MAX_FILENAME_LEN + 1;
1036        let mut buf = Vec::new();
1037        buf.push(0x02u8);
1038        buf.extend_from_slice(&0u64.to_le_bytes()); // content_length
1039        buf.extend_from_slice(&0u32.to_le_bytes()); // dirfd_index
1040        buf.extend_from_slice(&(name_len as u32).to_le_bytes()); // name_len
1041        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1042        let err = reader.next_chunk().unwrap_err();
1043        assert!(
1044            matches!(err, Error::FilenameTooLong { len, max } if len == name_len && max == MAX_FILENAME_LEN),
1045            "expected FilenameTooLong, got {err:?}"
1046        );
1047    }
1048
1049    #[test]
1050    fn error_inline_too_large() {
1051        // [0x00][(512 MiB) as u32 LE] — no body needed; length check fires first.
1052        let size: u32 = 512 * 1024 * 1024;
1053        let mut buf = Vec::new();
1054        buf.push(0x00u8);
1055        buf.extend_from_slice(&size.to_le_bytes());
1056        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1057        let err = reader.next_chunk().unwrap_err();
1058        assert!(
1059            matches!(err, Error::InlineTooLarge { size: s, max } if s == 512*1024*1024 && max == MAX_INLINE_CHUNK_SIZE),
1060            "expected InlineTooLarge, got {err:?}"
1061        );
1062    }
1063
1064    #[test]
1065    fn error_truncated_inline_content() {
1066        // [0x00][100u32 LE] then only 10 data bytes; Truncated{expected:100}.
1067        let mut buf = Vec::new();
1068        buf.push(0x00u8);
1069        buf.extend_from_slice(&100u32.to_le_bytes());
1070        buf.extend_from_slice(&[0u8; 10]);
1071        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1072        let err = reader.next_chunk().unwrap_err();
1073        assert!(
1074            matches!(err, Error::Truncated { expected: 100 }),
1075            "expected Truncated{{100}}, got {err:?}"
1076        );
1077    }
1078
1079    #[test]
1080    fn error_truncated_file_backed_data() {
1081        // [0x02] then only 5 of the 16 required header bytes.
1082        let mut buf = Vec::new();
1083        buf.push(0x02u8);
1084        buf.extend_from_slice(&[0u8; 5]);
1085        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1086        let err = reader.next_chunk().unwrap_err();
1087        assert!(
1088            matches!(err, Error::Truncated { .. }),
1089            "expected Truncated, got {err:?}"
1090        );
1091    }
1092
1093    #[test]
1094    fn error_truncated_prefix_after_type_byte() {
1095        // Type byte 0x00 is read, then EOF before the 4-byte length — Truncated{expected:4}.
1096        let buf = vec![0x00u8];
1097        let mut reader = SplitdirfdstreamReader::new(buf.as_slice());
1098        let err = reader.next_chunk().unwrap_err();
1099        assert!(
1100            matches!(err, Error::Truncated { expected: 4 }),
1101            "expected Truncated{{4}}, got {err:?}"
1102        );
1103    }
1104
1105    #[test]
1106    fn error_dirfd_index_out_of_range_via_reconstruct() {
1107        // External chunk references dirfd_index=5 but we supply 1 dirfd.
1108        let tmp = tempfile::tempdir().unwrap();
1109        let dir_fd = rustix::fs::open(
1110            tmp.path(),
1111            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1112            Mode::empty(),
1113        )
1114        .unwrap();
1115
1116        let mut buf = Vec::new();
1117        SplitdirfdstreamWriter::new(&mut buf)
1118            .write_file_backed_data(5, 0, b"dummy")
1119            .unwrap();
1120
1121        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1122        let mut out = Vec::new();
1123        let err = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap_err();
1124        assert!(
1125            matches!(err, Error::DirfdIndexOutOfRange { index: 5, count: 1 }),
1126            "expected DirfdIndexOutOfRange, got {err:?}"
1127        );
1128    }
1129
1130    // -------------------------------------------------------------------------
1131    // validate_filename tests (data-driven)
1132    // -------------------------------------------------------------------------
1133
1134    /// Expected outcome for a validate_filename test case.
1135    #[derive(Debug)]
1136    enum FilenameExpect {
1137        /// validate_filename must return Ok(()).
1138        Ok,
1139        /// validate_filename must return Err(InvalidFilename { reason }) where
1140        /// `reason` contains the given substring.
1141        InvalidFilename(&'static str),
1142        /// validate_filename must return Err(FilenameTooLong { .. }).
1143        TooLong,
1144    }
1145
1146    #[test]
1147    fn validate_filename_cases() {
1148        let long_ok: Vec<u8> = vec![b'a'; MAX_FILENAME_LEN];
1149        let long_bad: Vec<u8> = vec![b'a'; MAX_FILENAME_LEN + 1];
1150
1151        let cases: &[(&[u8], FilenameExpect)] = &[
1152            // ── rejection cases ──────────────────────────────────────────────
1153            (b"", FilenameExpect::InvalidFilename("empty filename")),
1154            (b"/abs", FilenameExpect::InvalidFilename("absolute path")),
1155            (b"a/../b", FilenameExpect::InvalidFilename("`..` component")),
1156            (
1157                b"../escape",
1158                FilenameExpect::InvalidFilename("`..` component"),
1159            ),
1160            (b"a/b/..", FilenameExpect::InvalidFilename("`..` component")),
1161            (b"..", FilenameExpect::InvalidFilename("`..` component")), // D2: bare `..`
1162            (b"foo\0bar", FilenameExpect::InvalidFilename("embedded NUL")),
1163            (&long_bad, FilenameExpect::TooLong),
1164            // ── acceptance cases ─────────────────────────────────────────────
1165            (b"a/b/c", FilenameExpect::Ok),   // normal relative path
1166            (b"a/./b", FilenameExpect::Ok),   // `.` is allowed
1167            (&long_ok, FilenameExpect::Ok),   // exactly MAX_FILENAME_LEN
1168            (b"..foo", FilenameExpect::Ok),   // D5: `..`-prefix but not a component
1169            (b"foo..", FilenameExpect::Ok),   // D5: `..`-suffix but not a component
1170            (b"a/..foo", FilenameExpect::Ok), // D5: `..`-prefixed component
1171            (b"foo../b", FilenameExpect::Ok), // D5: `..`-suffixed component in path
1172        ];
1173
1174        for (filename, expect) in cases {
1175            let result = validate_filename(filename);
1176            match expect {
1177                FilenameExpect::Ok => {
1178                    assert!(
1179                        result.is_ok(),
1180                        "validate_filename({filename:?}) should be Ok, got {result:?}"
1181                    );
1182                }
1183                FilenameExpect::InvalidFilename(substr) => {
1184                    assert!(
1185                        matches!(&result, Err(Error::InvalidFilename { reason }) if reason.contains(substr)),
1186                        "validate_filename({filename:?}) should be InvalidFilename containing {substr:?}, got {result:?}"
1187                    );
1188                }
1189                FilenameExpect::TooLong => {
1190                    assert!(
1191                        matches!(&result, Err(Error::FilenameTooLong { .. })),
1192                        "validate_filename({filename:?}) should be FilenameTooLong, got {result:?}"
1193                    );
1194                }
1195            }
1196        }
1197    }
1198
1199    // -------------------------------------------------------------------------
1200    // Reconstruction tests
1201    // -------------------------------------------------------------------------
1202
1203    fn open_dir(path: &std::path::Path) -> OwnedFd {
1204        rustix::fs::open(
1205            path,
1206            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1207            Mode::empty(),
1208        )
1209        .unwrap()
1210    }
1211
1212    #[test]
1213    fn reconstruct_inline_only() {
1214        let tmp = tempfile::tempdir().unwrap();
1215        let dir_fd = open_dir(tmp.path());
1216
1217        let mut buf = Vec::new();
1218        {
1219            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1220            w.write_metadata(b"Hello, ").unwrap();
1221            w.write_metadata(b"world!").unwrap();
1222            w.finish().unwrap();
1223        }
1224
1225        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1226        let mut out = Vec::new();
1227        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1228        assert_eq!(out, b"Hello, world!");
1229        assert_eq!(n, 13);
1230    }
1231
1232    #[test]
1233    fn reconstruct_with_externals() {
1234        let tmp = tempfile::tempdir().unwrap();
1235        std::fs::write(tmp.path().join("a"), b"FILEONE").unwrap();
1236        std::fs::create_dir(tmp.path().join("subdir")).unwrap();
1237        std::fs::write(tmp.path().join("subdir/b"), b"FILETWO").unwrap();
1238
1239        let dir_fd = open_dir(tmp.path());
1240
1241        let mut buf = Vec::new();
1242        {
1243            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1244            w.write_metadata(b"[").unwrap();
1245            w.write_file_backed_data(0, 7, b"a").unwrap();
1246            w.write_metadata(b"|").unwrap();
1247            w.write_file_backed_data(0, 7, b"subdir/b").unwrap();
1248            w.write_metadata(b"]").unwrap();
1249            w.finish().unwrap();
1250        }
1251
1252        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1253        let mut out = Vec::new();
1254        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1255        assert_eq!(out, b"[FILEONE|FILETWO]");
1256        assert_eq!(n, 17);
1257    }
1258
1259    #[test]
1260    fn reconstruct_same_file_twice() {
1261        let tmp = tempfile::tempdir().unwrap();
1262        std::fs::write(tmp.path().join("data"), b"REPEAT").unwrap();
1263        let dir_fd = open_dir(tmp.path());
1264
1265        let mut buf = Vec::new();
1266        {
1267            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1268            w.write_file_backed_data(0, 6, b"data").unwrap();
1269            w.write_metadata(b"-").unwrap();
1270            w.write_file_backed_data(0, 6, b"data").unwrap();
1271            w.finish().unwrap();
1272        }
1273
1274        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1275        let mut out = Vec::new();
1276        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1277        // pread always starts from offset 0, so both refs read the full file.
1278        assert_eq!(out, b"REPEAT-REPEAT");
1279        assert_eq!(n, 13);
1280    }
1281
1282    #[test]
1283    fn reconstruct_length_shorter_than_file() {
1284        let tmp = tempfile::tempdir().unwrap();
1285        std::fs::write(tmp.path().join("big"), b"ABCDEFGH").unwrap(); // 8 bytes
1286        let dir_fd = open_dir(tmp.path());
1287
1288        let mut buf = Vec::new();
1289        {
1290            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1291            w.write_file_backed_data(0, 4, b"big").unwrap(); // only first 4 bytes
1292            w.finish().unwrap();
1293        }
1294
1295        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1296        let mut out = Vec::new();
1297        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1298        assert_eq!(out, b"ABCD");
1299        assert_eq!(n, 4);
1300    }
1301
1302    #[test]
1303    fn reconstruct_length_longer_than_file_is_error() {
1304        let tmp = tempfile::tempdir().unwrap();
1305        std::fs::write(tmp.path().join("small"), b"HI").unwrap(); // 2 bytes
1306        let dir_fd = open_dir(tmp.path());
1307
1308        let mut buf = Vec::new();
1309        {
1310            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1311            w.write_file_backed_data(0, 100, b"small").unwrap(); // declare 100, file has 2
1312            w.finish().unwrap();
1313        }
1314
1315        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1316        let mut out = Vec::new();
1317        let err = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap_err();
1318        assert!(
1319            matches!(
1320                err,
1321                Error::ExternalTooShort {
1322                    declared: 100,
1323                    actual: 2
1324                }
1325            ),
1326            "expected ExternalTooShort, got {err:?}"
1327        );
1328    }
1329
1330    /// Build a raw splitdirfdstream buffer containing a single FileBackedData chunk
1331    /// with a given `length` field and filename, without going through
1332    /// the writer (so we can set length=u64::MAX which the writer itself would
1333    /// also accept).
1334    fn make_external_chunk_buf(dirfd_index: u32, length: u64, filename: &[u8]) -> Vec<u8> {
1335        let mut buf = Vec::new();
1336        buf.push(0x02u8); // type byte
1337        buf.extend_from_slice(&length.to_le_bytes()); // content_length
1338        buf.extend_from_slice(&dirfd_index.to_le_bytes()); // dirfd_index
1339        buf.extend_from_slice(&(filename.len() as u32).to_le_bytes()); // name_len
1340        buf.extend_from_slice(filename); // filename
1341        buf
1342    }
1343
1344    #[test]
1345    fn reconstruct_length_u64_max_does_not_overflow() {
1346        // Guards the buffer-sizing path: `BUF_SIZE.min(usize::try_from(length).unwrap_or(usize::MAX))`
1347        // must not panic or produce a wrong result when length = u64::MAX.
1348        // The file has only a few real bytes; the first pread reads them, the
1349        // second sees EOF and must return ExternalTooShort — not a panic.
1350        let tmp = tempfile::tempdir().unwrap();
1351        let fname = b"tiny";
1352        std::fs::write(tmp.path().join("tiny"), b"abc").unwrap(); // 3 bytes
1353        let dir_fd = open_dir(tmp.path());
1354
1355        let buf = make_external_chunk_buf(0, u64::MAX, fname);
1356
1357        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1358        let mut out = Vec::new();
1359        let err = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap_err();
1360        assert!(
1361            matches!(
1362                err,
1363                Error::ExternalTooShort {
1364                    declared: u64::MAX,
1365                    ..
1366                }
1367            ),
1368            "expected ExternalTooShort with declared=u64::MAX, got {err:?}"
1369        );
1370    }
1371
1372    #[test]
1373    fn reconstruct_multiple_dirfds() {
1374        let tmp0 = tempfile::tempdir().unwrap();
1375        let tmp1 = tempfile::tempdir().unwrap();
1376        std::fs::write(tmp0.path().join("x"), b"FROM0").unwrap();
1377        std::fs::write(tmp1.path().join("y"), b"FROM1").unwrap();
1378        let dir0 = open_dir(tmp0.path());
1379        let dir1 = open_dir(tmp1.path());
1380
1381        let mut buf = Vec::new();
1382        {
1383            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1384            w.write_file_backed_data(0, 5, b"x").unwrap();
1385            w.write_metadata(b"+").unwrap();
1386            w.write_file_backed_data(1, 5, b"y").unwrap();
1387            w.finish().unwrap();
1388        }
1389
1390        let dirfds: &[BorrowedFd<'_>] = &[dir0.as_fd(), dir1.as_fd()];
1391        let mut out = Vec::new();
1392        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1393        assert_eq!(out, b"FROM0+FROM1");
1394        assert_eq!(n, 11);
1395    }
1396
1397    // -------------------------------------------------------------------------
1398    // open_beneath safety tests
1399    // -------------------------------------------------------------------------
1400
1401    #[test]
1402    fn open_beneath_rejects_escape_via_dotdot() {
1403        // validate_filename catches ".." before any syscall, so this is
1404        // kernel-independent.
1405        let tmp = tempfile::tempdir().unwrap();
1406        let dir_fd = open_dir(tmp.path());
1407        let err = open_beneath(dir_fd.as_fd(), b"../anything").unwrap_err();
1408        assert!(
1409            matches!(
1410                err,
1411                Error::InvalidFilename {
1412                    reason: "`..` component"
1413                }
1414            ),
1415            "expected InvalidFilename, got {err:?}"
1416        );
1417    }
1418
1419    #[test]
1420    fn open_beneath_follows_symlink_within_base() {
1421        // A symlink whose target resolves *within* the base directory must be
1422        // followed successfully — this is the new behaviour after removing
1423        // RESOLVE_NO_SYMLINKS.  The l/<linkid> symlinks created by
1424        // containers/storage are exactly this kind of within-base symlink.
1425        let tmp = tempfile::tempdir().unwrap();
1426        std::fs::write(tmp.path().join("target"), b"data").unwrap();
1427        std::os::unix::fs::symlink("target", tmp.path().join("link")).unwrap();
1428
1429        let dir_fd = open_dir(tmp.path());
1430        let result = open_beneath(dir_fd.as_fd(), b"link");
1431        assert!(
1432            result.is_ok(),
1433            "symlink resolving within the base directory must be followed; got {result:?}"
1434        );
1435    }
1436
1437    #[test]
1438    fn open_beneath_rejects_escape_via_symlink() {
1439        // A symlink whose target escapes the base directory must be rejected
1440        // (RESOLVE_BENEATH) on kernels with openat2.  On old kernels the
1441        // fallback cannot enforce this; skip in that case.
1442        let tmp = tempfile::tempdir().unwrap();
1443        std::fs::create_dir(tmp.path().join("real")).unwrap();
1444        std::fs::write(tmp.path().join("real/passwd"), b"data").unwrap();
1445        // Symlink to /etc — outside the base dir.
1446        std::os::unix::fs::symlink("/etc", tmp.path().join("link")).unwrap();
1447
1448        let dir_fd = open_dir(tmp.path());
1449
1450        // Probe openat2 availability.
1451        let probe = rustix::fs::openat2(
1452            dir_fd.as_fd(),
1453            rustix::cstr!("real/passwd"),
1454            OFlags::RDONLY | OFlags::CLOEXEC,
1455            Mode::empty(),
1456            ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV,
1457        );
1458        if let Err(e) = probe
1459            && e == Errno::NOSYS
1460        {
1461            // openat2 not available: skip this test.
1462            return;
1463        }
1464
1465        // openat2 is available: symlink escaping outside the base must be rejected.
1466        let result = open_beneath(dir_fd.as_fd(), b"link/passwd");
1467        assert!(
1468            result.is_err(),
1469            "openat2 path must reject symlink escaping the base directory; got Ok"
1470        );
1471    }
1472
1473    // -------------------------------------------------------------------------
1474    // InlineData chunk tests
1475    // -------------------------------------------------------------------------
1476
1477    #[test]
1478    fn file_content_wire_layout() {
1479        // Exact bytes: [0x01][5u32 LE][b"hello"]
1480        let data = b"hello";
1481        let mut buf = Vec::new();
1482        SplitdirfdstreamWriter::new(&mut buf)
1483            .write_inline_data(data)
1484            .unwrap();
1485
1486        let expected: Vec<u8> = {
1487            let mut v = Vec::new();
1488            v.push(0x01u8); // type byte
1489            v.extend_from_slice(&5u32.to_le_bytes()); // length
1490            v.extend_from_slice(b"hello"); // data
1491            v
1492        };
1493        assert_eq!(buf, expected, "InlineData wire layout mismatch");
1494    }
1495
1496    #[test]
1497    fn roundtrip_file_content_zero_bytes() {
1498        // Zero-length FileContent must still be written and round-trip.
1499        let (buf, chunks) = roundtrip_stream(&[WriteCmd::InlineData(b"")]);
1500        // 1-byte type + 4-byte u32 length + 0 data = 5 bytes
1501        assert_eq!(buf.len(), 5, "zero-length InlineData should be 5 bytes");
1502        assert_eq!(chunks, vec![DecodedChunk::InlineData(vec![])]);
1503    }
1504
1505    #[test]
1506    fn roundtrip_file_content() {
1507        for size in [0usize, 1, 64, 65, 4096] {
1508            let data: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
1509            let (buf, chunks) = roundtrip_stream(&[WriteCmd::InlineData(&data)]);
1510
1511            // Wire layout: 1-byte type + 4-byte u32 length + `size` bytes
1512            assert_eq!(buf.len(), 5 + size, "buf.len() for InlineData size={size}");
1513            assert_eq!(
1514                chunks,
1515                vec![DecodedChunk::InlineData(data)],
1516                "decoded data for size={size}"
1517            );
1518        }
1519    }
1520
1521    #[test]
1522    fn reconstruct_file_content() {
1523        // FileContent in a stream reconstructs to the verbatim bytes.
1524        let tmp = tempfile::tempdir().unwrap();
1525        let dir_fd = open_dir(tmp.path());
1526
1527        let mut buf = Vec::new();
1528        {
1529            let mut w = SplitdirfdstreamWriter::new(&mut buf);
1530            w.write_metadata(b"[").unwrap();
1531            w.write_inline_data(b"CONTENT").unwrap();
1532            w.write_metadata(b"]").unwrap();
1533            w.finish().unwrap();
1534        }
1535
1536        let dirfds: &[BorrowedFd<'_>] = &[dir_fd.as_fd()];
1537        let mut out = Vec::new();
1538        let n = reconstruct(buf.as_slice(), dirfds, &mut out).unwrap();
1539        assert_eq!(out, b"[CONTENT]");
1540        assert_eq!(n, 9);
1541    }
1542
1543    // -------------------------------------------------------------------------
1544    // Proptest suite
1545    // -------------------------------------------------------------------------
1546
1547    mod proptest_tests {
1548        use super::*;
1549        use proptest::prelude::*;
1550
1551        /// Strategy: 1–4 path components of [a-z0-9]{1,8} joined by '/',
1552        /// always stays below MAX_FILENAME_LEN and never contains `..`.
1553        fn filename_strategy() -> impl Strategy<Value = Vec<u8>> {
1554            let component = prop::string::string_regex("[a-z0-9]{1,8}").unwrap();
1555            prop::collection::vec(component, 1..=4).prop_map(|parts| parts.join("/").into_bytes())
1556        }
1557
1558        /// A chunk that carries its own content for proptest generation.
1559        #[derive(Debug, Clone)]
1560        enum TestChunk {
1561            Metadata(Vec<u8>),
1562            InlineData(Vec<u8>),
1563            FileBackedData {
1564                /// Unnormalized index in `0..4`; normalized to `% num_dirs` at test time.
1565                dirfd_index: usize,
1566                filename: Vec<u8>,
1567                content: Vec<u8>,
1568            },
1569        }
1570
1571        fn metadata_strategy() -> impl Strategy<Value = TestChunk> {
1572            prop::collection::vec(any::<u8>(), 1..=4096).prop_map(TestChunk::Metadata)
1573        }
1574
1575        fn file_backed_data_strategy() -> impl Strategy<Value = TestChunk> {
1576            (
1577                0usize..4,
1578                filename_strategy(),
1579                prop::collection::vec(any::<u8>(), 0..=8192),
1580            )
1581                .prop_map(|(idx, name, content)| TestChunk::FileBackedData {
1582                    dirfd_index: idx,
1583                    filename: name,
1584                    content,
1585                })
1586        }
1587
1588        fn inline_data_strategy() -> impl Strategy<Value = TestChunk> {
1589            prop::collection::vec(any::<u8>(), 0..=4096).prop_map(TestChunk::InlineData)
1590        }
1591
1592        fn chunk_strategy() -> impl Strategy<Value = TestChunk> {
1593            prop_oneof![
1594                metadata_strategy(),
1595                file_backed_data_strategy(),
1596                inline_data_strategy()
1597            ]
1598        }
1599
1600        fn chunks_strategy() -> impl Strategy<Value = Vec<TestChunk>> {
1601            prop::collection::vec(chunk_strategy(), 0..=32)
1602        }
1603
1604        proptest! {
1605            #![proptest_config(ProptestConfig::with_cases(256))]
1606
1607            #[test]
1608            fn proptest_roundtrip(chunks in chunks_strategy()) {
1609                use std::collections::HashMap;
1610
1611                let num_dirs = 4usize;
1612                let tmpdirs: Vec<tempfile::TempDir> = (0..num_dirs)
1613                    .map(|_| tempfile::tempdir().unwrap())
1614                    .collect();
1615
1616                // Normalize chunks: assign each External chunk a name of the
1617                // form "N/<orig>" where N is a small bucket index (0..=3).
1618                // Using only 4 buckets instead of a per-chunk unique counter
1619                // means some chunks will intentionally share (dir_idx, name).
1620                //
1621                // When two chunks share a (dir_idx, name) key, the *first*
1622                // chunk's content wins: its bytes are written to disk and both
1623                // references reconstruct the same content, so the expected
1624                // output stays well-defined.  This exercises the pread-from-
1625                // offset-0 restart property (same file opened twice).
1626                #[derive(Debug)]
1627                enum ResolvedChunk {
1628                    Metadata(Vec<u8>),
1629                    InlineData(Vec<u8>),
1630                    FileBackedData { dir_idx: usize, unique_name: Vec<u8>, content: Vec<u8> },
1631                }
1632
1633                // Map (dir_idx, unique_name) -> first-seen content.
1634                let mut content_map: HashMap<(usize, Vec<u8>), Vec<u8>> = HashMap::new();
1635
1636                let mut resolved: Vec<ResolvedChunk> = Vec::with_capacity(chunks.len());
1637                let mut ext_counter = 0usize;
1638                for chunk in &chunks {
1639                    match chunk {
1640                        TestChunk::Metadata(data) => {
1641                            resolved.push(ResolvedChunk::Metadata(data.clone()));
1642                        }
1643                        TestChunk::InlineData(data) => {
1644                            resolved.push(ResolvedChunk::InlineData(data.clone()));
1645                        }
1646                        TestChunk::FileBackedData { dirfd_index, filename, content } => {
1647                            let dir_idx = dirfd_index % num_dirs;
1648                            // Flatten any '/' in the generated name so a file
1649                            // path can never be a *prefix* of another file path
1650                            // (which on disk would require the prefix to be both
1651                            // a regular file and a directory, an impossible
1652                            // fixture that yields ENOTDIR). The library itself
1653                            // supports nested paths; this only keeps the
1654                            // on-disk test fixtures internally consistent.
1655                            let orig = std::str::from_utf8(filename)
1656                                .unwrap()
1657                                .replace('/', "_");
1658                            // Bucket index cycles through 0..=3 so collisions
1659                            // happen with probability ~(1 - 3/4^k) for k chunks,
1660                            // exercising the "same file referenced twice" path.
1661                            let bucket = ext_counter % 4;
1662                            ext_counter += 1;
1663                            let unique_name =
1664                                format!("{bucket}/{orig}").into_bytes();
1665                            // Canonical content: first writer wins.
1666                            let key = (dir_idx, unique_name.clone());
1667                            let canonical = content_map
1668                                .entry(key)
1669                                .or_insert_with(|| content.clone())
1670                                .clone();
1671                            resolved.push(ResolvedChunk::FileBackedData {
1672                                dir_idx,
1673                                unique_name,
1674                                content: canonical,
1675                            });
1676                        }
1677                    }
1678                }
1679
1680                // Materialize external files on disk (write each unique path once;
1681                // duplicates are skipped because the file already exists).
1682                for chunk in &resolved {
1683                    if let ResolvedChunk::FileBackedData { dir_idx, unique_name, content } = chunk {
1684                        let path = tmpdirs[*dir_idx].path().join(
1685                            std::str::from_utf8(unique_name).unwrap()
1686                        );
1687                        if !path.exists() {
1688                            if let Some(parent) = path.parent() {
1689                                std::fs::create_dir_all(parent).unwrap();
1690                            }
1691                            std::fs::write(&path, content).unwrap();
1692                        }
1693                    }
1694                }
1695
1696                // Build the stream and expected output.
1697                let mut stream_buf = Vec::new();
1698                let mut expected_output: Vec<u8> = Vec::new();
1699                {
1700                    let mut w = SplitdirfdstreamWriter::new(&mut stream_buf);
1701                    for chunk in &resolved {
1702                        match chunk {
1703                            ResolvedChunk::Metadata(data) => {
1704                                w.write_metadata(data).unwrap();
1705                                expected_output.extend_from_slice(data);
1706                            }
1707                            ResolvedChunk::InlineData(data) => {
1708                                w.write_inline_data(data).unwrap();
1709                                expected_output.extend_from_slice(data);
1710                            }
1711                            ResolvedChunk::FileBackedData { dir_idx, unique_name, content } => {
1712                                let len = content.len() as u64;
1713                                w.write_file_backed_data(*dir_idx as u32, len, unique_name).unwrap();
1714                                expected_output.extend_from_slice(content);
1715                            }
1716                        }
1717                    }
1718                    w.finish().unwrap();
1719                }
1720
1721                // Open dir fds.
1722                let dir_fds: Vec<OwnedFd> = tmpdirs
1723                    .iter()
1724                    .map(|d| open_dir(d.path()))
1725                    .collect();
1726                let borrowed: Vec<BorrowedFd<'_>> = dir_fds.iter().map(|fd| fd.as_fd()).collect();
1727
1728                // Verify chunk sequence matches what we wrote.
1729                {
1730                    let mut reader = SplitdirfdstreamReader::new(stream_buf.as_slice());
1731                    let mut res_iter = resolved.iter();
1732                    while let Some(chunk) = reader.next_chunk().unwrap() {
1733                        let expected = res_iter.next().unwrap();
1734                        match (&chunk, expected) {
1735                            (Chunk::Metadata(data), ResolvedChunk::Metadata(exp)) => {
1736                                prop_assert_eq!(*data, exp.as_slice());
1737                            }
1738                            (Chunk::InlineData(data), ResolvedChunk::InlineData(exp)) => {
1739                                prop_assert_eq!(*data, exp.as_slice());
1740                            }
1741                            (
1742                                Chunk::FileBackedData { dirfd_index, length, filename },
1743                                ResolvedChunk::FileBackedData { dir_idx, unique_name, content },
1744                            ) => {
1745                                prop_assert_eq!(*dirfd_index, *dir_idx as u32);
1746                                prop_assert_eq!(*length, content.len() as u64);
1747                                prop_assert_eq!(*filename, unique_name.as_slice());
1748                            }
1749                            _ => {
1750                                return Err(TestCaseError::fail("chunk type mismatch"));
1751                            }
1752                        }
1753                    }
1754                }
1755
1756                // Verify reconstruction produces the expected concatenation.
1757                let mut out = Vec::new();
1758                reconstruct(stream_buf.as_slice(), &borrowed, &mut out).unwrap();
1759                prop_assert_eq!(out, expected_output);
1760            }
1761        }
1762    }
1763}