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}