Skip to main content

ferroday_cage/provision/
copyin.rs

1//! Copying a host tree into a rootfs at the ownership the rootfs intends.
2//!
3//! The caller-facing half of [`mechanism::copyin`](crate::mechanism): this side
4//! walks the host source with an allocator and a heap, and streams frames to
5//! the in-map child that creates each entry. See that module for the frame
6//! protocol and the post-fork discipline the child works under.
7//!
8//! Two things live here rather than in the child because they need a heap. The
9//! hardlink table recognizes that two names are one file and emits the second
10//! as a link onto the first, so a tree keeps the sharing it was written with —
11//! the same table, and the same reason, as the export's. And the skip report
12//! names every entry the copy could not create, so a rootfs that ends up
13//! missing something says so.
14
15use std::collections::HashMap;
16use std::ffi::{CStr, CString, OsStr};
17use std::fmt;
18use std::io::{self, Read};
19use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
20use std::os::unix::ffi::OsStrExt;
21use std::path::{Path, PathBuf};
22
23use rustix::fs::{self, AtFlags, OFlags};
24
25use super::identity::compose_child_map;
26use super::{Delegate, ProvisionError};
27use crate::IdentityMap;
28use crate::idmap::{IdMapper, IdRange};
29use crate::mechanism::bounds::{DATA_BUF, LINK_BUF, MAX_DEPTH};
30use crate::mechanism::frame;
31use crate::mechanism::{
32    COPY_DIR, COPY_END, COPY_FIFO, COPY_FILE, COPY_LEAVE, COPY_LINK, COPY_SYMLINK, ChildMap,
33    CopyFailure, start_copy_in,
34};
35use crate::path;
36
37/// Copies a host directory tree into a rootfs, with each entry created under
38/// the identity map the rootfs is owned through.
39///
40// The paragraph links a feature-gated item, so its doc text is gated on the
41// feature: present in the canonical all-features build, absent — link and all —
42// from a no-feature `cargo doc`.
43#[cfg_attr(
44    feature = "tarball",
45    doc = "
46The mirror image of [`Export`](super::Export). An export forks a child into
47the map to *read* a tree at the ownership it intends; this forks one to
48*write* one.
49
50The mirroring is of the mechanism, not of the entry types. An export
51describes a rootfs the cage built, for a runtime that will supply its own
52device nodes and pipes; a copy lays down a tree the caller wrote by hand,
53where every entry is there because someone put it there. So the copy creates
54everything it can create — a named pipe included — and names the rest.
55
56"
57)]
58/// A source entry's host ownership is read back through the map to the id it
59/// means inside the rootfs. So a tree an earlier mapped sandbox produced copies
60/// in owned as it was written, and a tree the caller staged as themselves lands
61/// as whatever id the map gives the calling user — root, under a
62/// [`Single`](IdentityMap::Single) map.
63///
64/// That is the thing a plain `cp` structurally cannot do. `cp` runs as the
65/// calling user, which can create a file owned by nobody else, so a tree laid
66/// into a rootfs by hand arrives owned by the caller's own host id, whatever the
67/// source intended — an id a range-mapped rootfs has no name for at all.
68///
69/// The destination must already exist. Entries are added to it: a directory the
70/// rootfs already carries is kept and descended into, and a file, symlink, or
71/// named pipe is replaced.
72///
73/// Directories, regular files, symbolic links, and named pipes are copied.
74/// Several names for one file are copied as hard links, so a tree keeps the
75/// sharing it was written with. Character and block devices and sockets cannot
76/// be copied and are named in the [`CopyReport`] the run returns; see
77/// [`SkippedKind`] for why each is left out.
78///
79/// # Example
80///
81/// ```no_run
82/// use ferroday_cage::IdentityMap;
83/// use ferroday_cage::provision::CopyIn;
84///
85/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
86/// let report = CopyIn::new("./overlay", "/srv/images/trixie")
87///     .map(IdentityMap::Subordinate)
88///     .run()?;
89///
90/// for entry in &report.skipped {
91///     eprintln!("not copied: {} ({})", entry.path.display(), entry.kind);
92/// }
93/// # Ok(())
94/// # }
95/// ```
96pub struct CopyIn<'a> {
97    source: PathBuf,
98    rootfs: PathBuf,
99    map: IdentityMap,
100    mapper: Option<&'a dyn IdMapper>,
101}
102
103impl fmt::Debug for CopyIn<'_> {
104    /// Renders every setting, and the mapper by its presence rather than its
105    /// contents: it is a caller's trait object, with nothing useful to show.
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.debug_struct("CopyIn")
108            .field("source", &self.source)
109            .field("rootfs", &self.rootfs)
110            .field("map", &self.map)
111            .field(
112                "mapper",
113                &self.mapper.as_ref().map(|_| Delegate("dyn IdMapper")),
114            )
115            .finish()
116    }
117}
118
119impl<'a> CopyIn<'a> {
120    /// Returns a copy of the tree at `source` into the rootfs at `rootfs`.
121    ///
122    /// The identity map defaults to [`IdentityMap::Single`], under which the
123    /// in-map child writes its own one-id map and every entry lands as root —
124    /// what a single-mapped sandbox sees, and what such a sandbox could have
125    /// written itself.
126    pub fn new(source: impl AsRef<Path>, rootfs: impl AsRef<Path>) -> CopyIn<'a> {
127        CopyIn {
128            source: source.as_ref().to_path_buf(),
129            rootfs: rootfs.as_ref().to_path_buf(),
130            map: IdentityMap::Single,
131            mapper: None,
132        }
133    }
134
135    /// Sets the identity map the rootfs is owned through.
136    ///
137    /// It must be the map the rootfs was built under, for the reason an export
138    /// must name the same one: the ids on the host are the rootfs's ids offset
139    /// by the map, so a copy made through a different map writes ownership the
140    /// rootfs will not read back as intended.
141    pub fn map(mut self, map: IdentityMap) -> CopyIn<'a> {
142        self.map = map;
143        self
144    }
145
146    /// Copies through a caller-supplied identity-map delegate, for a rootfs
147    /// built under [`CageBuilder::id_mapper`].
148    ///
149    /// Without one, the copy resolves the bundled delegate chain, which covers
150    /// a [`Subordinate`](IdentityMap::Subordinate) rootfs and a
151    /// bundled-delegate [`Ranges`](IdentityMap::Ranges) one. A
152    /// [`Single`](IdentityMap::Single) map self-establishes and ignores the
153    /// mapper, as the launch does.
154    ///
155    /// [`CageBuilder::id_mapper`]: crate::CageBuilder::id_mapper
156    pub fn mapper(mut self, mapper: &'a dyn IdMapper) -> CopyIn<'a> {
157        self.mapper = Some(mapper);
158        self
159    }
160
161    /// Performs the copy, reporting what it could not copy.
162    ///
163    /// # Errors
164    ///
165    /// [`ProvisionError::CopyUnprivileged`] when the rootfs needs a range map
166    /// and no delegate can establish one; [`ProvisionError::EntryRefused`] for a
167    /// source entry the copy cannot carry — a tree deeper than the walk
168    /// descends, a name or a link target longer than a frame holds, or an
169    /// ownership the rootfs's identity map has no id for;
170    /// [`ProvisionError::SourceChanged`] for a file that changed size while it
171    /// was being read; and [`ProvisionError::Io`] for a failure of the copy
172    /// machinery, of the walk, or of the in-map writer.
173    pub fn run(self) -> Result<CopyReport, ProvisionError> {
174        copy_in_inner(&self.source, &self.rootfs, &self.map, self.mapper)
175    }
176}
177
178/// What a [`CopyIn`] run left out.
179///
180/// A copy that silently dropped an entry would produce a rootfs that differs
181/// from the tree the caller wrote, with nothing to say so. Every source entry
182/// the copy did not create is named here instead, so a caller can treat the
183/// omission as a warning, as an error, or as expected.
184///
185/// An empty [`skipped`](Self::skipped) means the destination now holds
186/// everything the source did.
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188#[non_exhaustive]
189pub struct CopyReport {
190    /// Every source entry the copy did not create, in the order the walk
191    /// reached them.
192    pub skipped: Vec<SkippedEntry>,
193}
194
195/// One source entry a [`CopyIn`] run did not create.
196#[derive(Debug, Clone, PartialEq, Eq)]
197#[non_exhaustive]
198pub struct SkippedEntry {
199    /// The entry's path in the source tree, as the walk reached it.
200    pub path: PathBuf,
201    /// What the entry is, which is why it was left out.
202    pub kind: SkippedKind,
203}
204
205/// The kind of source entry a copy cannot create.
206///
207/// Every other kind — a directory, a regular file, a symbolic link, a named
208/// pipe, and a second name for a file already copied — is copied.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210#[non_exhaustive]
211pub enum SkippedKind {
212    /// A character device.
213    ///
214    /// Creating a device node needs `CAP_MKNOD` in the *initial* user
215    /// namespace, which the copy child does not hold however it is mapped, so
216    /// this is a limit of the mechanism rather than a choice. A rootfs's
217    /// runtime supplies its own device nodes.
218    CharacterDevice,
219    /// A block device, for the reason a character device is skipped.
220    BlockDevice,
221    /// A unix-domain socket.
222    ///
223    /// A socket's inode is created by the process that binds it and is
224    /// meaningless without that process; copying the inode would produce a
225    /// path that looks connectable and is not.
226    Socket,
227}
228
229impl fmt::Display for SkippedKind {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let described = match self {
232            SkippedKind::CharacterDevice => {
233                "a character device, which needs a privilege the copy does not hold"
234            }
235            SkippedKind::BlockDevice => {
236                "a block device, which needs a privilege the copy does not hold"
237            }
238            SkippedKind::Socket => "a socket, which only the process that binds it can create",
239        };
240        f.write_str(described)
241    }
242}
243
244/// Copies `source` into `rootfs`, resolving a range map through `mapper` when
245/// given and the bundled delegate chain otherwise.
246fn copy_in_inner(
247    source: &Path,
248    rootfs: &Path,
249    map: &IdentityMap,
250    mapper: Option<&dyn IdMapper>,
251) -> Result<CopyReport, ProvisionError> {
252    let rootfs_c = CString::new(rootfs.as_os_str().as_bytes()).map_err(|_| {
253        ProvisionError::io(
254            "copying into",
255            rootfs,
256            io::Error::from(io::ErrorKind::InvalidInput),
257        )
258    })?;
259    if !source.is_dir() {
260        return Err(ProvisionError::io(
261            "copying",
262            source,
263            io::Error::from(io::ErrorKind::NotADirectory),
264        ));
265    }
266
267    let map_source =
268        compose_child_map(map, mapper).map_err(|reason| ProvisionError::CopyUnprivileged {
269            path: rootfs.to_path_buf(),
270            reason,
271        })?;
272    let child_map = map_source.child_map();
273
274    // The extents the source's ids are read through, as host-to-inside pairs.
275    // See `Translation`.
276    let translation = Translation::of(&child_map);
277
278    let child = start_copy_in(&rootfs_c, child_map).map_err(|err| failure(err, rootfs))?;
279
280    // The walk writes to the child's stream. A failure part-way leaves the
281    // child mid-tree, so it is killed rather than left to read an input that
282    // will never end.
283    //
284    // The child's own account comes first where it left one. The walk's most
285    // common failure is a frame write, and a frame write fails only because the
286    // child has already died — of a step this side cannot see, an id the map
287    // refuses or an entry the destination will not take — so this side's broken
288    // pipe would name the symptom and drop the cause.
289    let report = match walk(child.stream(), source, &translation) {
290        Ok(report) => report,
291        Err(err) => {
292            return Err(match child.abandon() {
293                Some(reported) => failure(reported, rootfs),
294                None => err,
295            });
296        }
297    };
298    child.finish().map_err(|err| failure(err, rootfs))?;
299    Ok(report)
300}
301
302/// Turns a child-side failure into the caller's error.
303fn failure(err: CopyFailure, rootfs: &Path) -> ProvisionError {
304    match err {
305        CopyFailure::Map(err) => ProvisionError::CopyUnprivileged {
306            path: rootfs.to_path_buf(),
307            reason: err.to_string(),
308        },
309        CopyFailure::Step { step, errno } => ProvisionError::Io {
310            op: step.describe(),
311            path: rootfs.to_path_buf(),
312            source: io::Error::from_raw_os_error(errno),
313        },
314        CopyFailure::Malformed => ProvisionError::Io {
315            op: "reading the copy process's outcome",
316            path: rootfs.to_path_buf(),
317            source: io::Error::from_raw_os_error(rustix::io::Errno::PROTO.raw_os_error()),
318        },
319    }
320}
321
322/// Walks `source` and streams its contents as frames.
323///
324/// The source root itself is not emitted: its contents land directly in the
325/// destination, so a copy of `./overlay` into a rootfs puts `./overlay/etc` at
326/// `/etc`. That matches what a caller means by laying a tree in, and it means
327/// the destination's own mode and ownership are never rewritten by a copy.
328fn walk(
329    stream: BorrowedFd<'_>,
330    source: &Path,
331    ids: &Translation,
332) -> Result<CopyReport, ProvisionError> {
333    let mut walk = Walk {
334        ids,
335        links: HashMap::new(),
336        prefix: Vec::new(),
337        report: CopyReport::default(),
338    };
339    // The whole walk is relative to a descriptor: every step below resolves a
340    // name against the directory this opened rather than against a path, so
341    // what was typed is what is read. See `Walk::dir`.
342    let root = open_dir(source, source)?;
343    walk.dir(stream, root.as_fd(), source, 0)?;
344    put(stream, &[COPY_END], source)?;
345    Ok(walk.report)
346}
347
348/// The state one walk carries: the id translation, the hardlink table, the
349/// path of the directory currently being emitted, and what has been skipped.
350struct Walk<'a> {
351    /// The map, read backwards, applied to every entry's ownership.
352    ids: &'a Translation,
353    /// The first name emitted for each multiply-linked inode, keyed by device
354    /// and inode number and held as a path relative to the destination root.
355    ///
356    /// The parent keeps this for the same reason the export does: the child
357    /// has no heap and no path assembly, so recognizing that two names are one
358    /// file is work only this side can do.
359    links: HashMap<(u64, u64), Vec<u8>>,
360    /// The current directory's path relative to the destination root, with a
361    /// trailing separator, so appending a name gives that entry's own path.
362    /// Empty at the top of the tree.
363    prefix: Vec<u8>,
364    /// What the walk could not copy.
365    report: CopyReport,
366}
367
368impl Walk<'_> {
369    /// Emits every entry of `dir`, descending into subdirectories.
370    ///
371    /// `dir` is a descriptor, and every step resolves its name against that
372    /// descriptor rather than against a path: the entry that is typed is the
373    /// entry that is then read, opened, or descended into. A path-based walk
374    /// resolves each name afresh at every step, so an entry swapped for a
375    /// symbolic link between being typed and being acted on is followed — a
376    /// directory descended out of the source, or worse, a file streamed from
377    /// wherever the link points. `path` is carried alongside for diagnostics
378    /// and for the report, and names nothing this resolves.
379    ///
380    /// A symbolic link is therefore copied as a symbolic link and never
381    /// traversed, whatever it points at, and the child opens each destination
382    /// directory with `O_NOFOLLOW` for the same reason on its side.
383    ///
384    /// Entries are sorted by name, so a copy of one tree is one sequence of
385    /// frames whatever order the filesystem hands them back — the same reason
386    /// the export sorts its own walk. Sorting also fixes which name of a
387    /// multiply-linked file carries the data and which become links, so two
388    /// copies of one tree produce the same frames.
389    fn dir(
390        &mut self,
391        stream: BorrowedFd<'_>,
392        dir: BorrowedFd<'_>,
393        path: &Path,
394        depth: usize,
395    ) -> Result<(), ProvisionError> {
396        if depth >= MAX_DEPTH {
397            return Err(ProvisionError::EntryRefused {
398                path: path.to_path_buf(),
399                reason: format!("the source tree is deeper than {MAX_DEPTH} levels"),
400            });
401        }
402        let mut entries = read_names(dir, path)?;
403        entries.sort();
404
405        for name in entries {
406            let entry = path.join(OsStr::from_bytes(&name));
407            // The child refuses anything that is not a single ordinary
408            // component, and refusing here names the host path that carries it
409            // rather than leaving the child to report a name with no context.
410            if !path::is_ordinary_component(&name) {
411                return Err(ProvisionError::EntryRefused {
412                    path: entry,
413                    reason: "the entry name is not a single path component".to_string(),
414                });
415            }
416            if name.len() > u16::from(u8::MAX) as usize {
417                return Err(ProvisionError::EntryRefused {
418                    path: entry,
419                    reason: "the entry name is longer than NAME_MAX".to_string(),
420                });
421            }
422            let leaf = leaf_cstring(&name, &entry)?;
423
424            // `AT_SYMLINK_NOFOLLOW`, so a symlink is typed as a symlink rather
425            // than as whatever it points at.
426            let meta = fs::statat(dir, &leaf, AtFlags::SYMLINK_NOFOLLOW)
427                .map_err(|errno| ProvisionError::io("reading", &entry, errno.into()))?;
428            let kind = fs::FileType::from_raw_mode(meta.st_mode);
429            if kind == fs::FileType::Directory {
430                self.emit_named(stream, COPY_DIR, &name, &meta, &entry)?;
431                let restore = self.prefix.len();
432                self.prefix.extend_from_slice(&name);
433                self.prefix.push(b'/');
434                // `O_NOFOLLOW` as well as `O_DIRECTORY`: an entry that became a
435                // symlink between the stat above and this open is refused here
436                // rather than descended through.
437                let child = openat_nofollow(dir, &leaf, OFlags::DIRECTORY, &entry)?;
438                self.dir(stream, child.as_fd(), &entry, depth + 1)?;
439                self.prefix.truncate(restore);
440                put(stream, &[COPY_LEAVE], &entry)?;
441            } else if kind == fs::FileType::RegularFile {
442                if self.emit_link(stream, &name, &meta, &entry)? {
443                    continue;
444                }
445                // Opened before the length is announced, so the descriptor the
446                // bytes come from is the file that was typed.
447                let file = openat_nofollow(dir, &leaf, OFlags::empty(), &entry)?;
448                let size = meta.st_size as u64;
449                self.emit_named(stream, COPY_FILE, &name, &meta, &entry)?;
450                put(stream, &size.to_le_bytes(), &entry)?;
451                stream_file(stream, file, &entry, size)?;
452            } else if kind == fs::FileType::Symlink {
453                let mut buffer = [0u8; LINK_BUF];
454                let read = fs::readlinkat_raw(dir, &leaf, &mut buffer[..])
455                    .map_err(|errno| ProvisionError::io("reading", &entry, errno.into()))?;
456                // `readlinkat` truncates a target too long for the buffer and
457                // reports the truncated length as a success, so a full buffer is
458                // the only signal there is: a target that fills it is refused
459                // rather than copied as a link pointing somewhere else.
460                if read == buffer.len() {
461                    return Err(ProvisionError::EntryRefused {
462                        path: entry,
463                        reason: "the symlink target is longer than a frame can carry".to_string(),
464                    });
465                }
466                let target = &buffer[..read];
467                self.emit_named(stream, COPY_SYMLINK, &name, &meta, &entry)?;
468                put(stream, &(target.len() as u16).to_le_bytes(), &entry)?;
469                put(stream, target, &entry)?;
470            } else if kind == fs::FileType::Fifo {
471                // A named pipe is an ordinary unprivileged creation, so the
472                // copy makes one rather than dropping it: a tree that ships a
473                // FIFO meant to ship it.
474                self.emit_named(stream, COPY_FIFO, &name, &meta, &entry)?;
475            } else {
476                let skipped = match kind {
477                    fs::FileType::CharacterDevice => SkippedKind::CharacterDevice,
478                    fs::FileType::BlockDevice => SkippedKind::BlockDevice,
479                    _ => SkippedKind::Socket,
480                };
481                self.report.skipped.push(SkippedEntry {
482                    path: entry,
483                    kind: skipped,
484                });
485            }
486        }
487        Ok(())
488    }
489
490    /// Emits a [`COPY_LINK`] frame when this file has already been copied
491    /// under another name, and reports whether it did.
492    ///
493    /// A file with one link is never recorded: the table would then hold every
494    /// regular file in the tree to answer a question that can only be no.
495    ///
496    /// The device and inode widths are the architecture's; see
497    /// [`emit_named`](Self::emit_named).
498    #[allow(clippy::unnecessary_cast, clippy::useless_conversion)]
499    fn emit_link(
500        &mut self,
501        stream: BorrowedFd<'_>,
502        name: &[u8],
503        meta: &fs::Stat,
504        path: &Path,
505    ) -> Result<bool, ProvisionError> {
506        if meta.st_nlink <= 1 {
507            return Ok(false);
508        }
509        let key = (meta.st_dev as u64, meta.st_ino as u64);
510        if let Some(anchor) = self.links.get(&key) {
511            put(stream, &[COPY_LINK], path)?;
512            put(stream, &(name.len() as u16).to_le_bytes(), path)?;
513            put(stream, name, path)?;
514            put(stream, &(anchor.len() as u16).to_le_bytes(), path)?;
515            put(stream, anchor, path)?;
516            return Ok(true);
517        }
518
519        let mut anchor = self.prefix.clone();
520        anchor.extend_from_slice(name);
521        // The child resolves the anchor into a fixed buffer, so a path it
522        // could not hold is refused here, with the path that carries it.
523        if anchor.len() > LINK_BUF {
524            return Err(ProvisionError::EntryRefused {
525                path: path.to_path_buf(),
526                reason: "the path of a multiply-linked file is longer than a frame can carry"
527                    .to_string(),
528            });
529        }
530        self.links.insert(key, anchor);
531        Ok(false)
532    }
533
534    /// Emits a frame's tag, name, and metadata block.
535    ///
536    /// A `stat`'s field widths are the architecture's own — `st_nlink` is 32
537    /// bits on some and 64 on others, and the time fields likewise — so the
538    /// conversions to the frame's fixed widths are the identity on one target
539    /// and a widening on another. `allow` rather than `expect`, for the reason
540    /// the export's link count carries: an expectation of either lint is
541    /// fulfilled on some targets and not others, and an unfulfilled one fails
542    /// the clippy job there.
543    #[allow(clippy::unnecessary_cast, clippy::useless_conversion)]
544    fn emit_named(
545        &self,
546        stream: BorrowedFd<'_>,
547        tag: u8,
548        name: &[u8],
549        meta: &fs::Stat,
550        path: &Path,
551    ) -> Result<(), ProvisionError> {
552        put(stream, &[tag], path)?;
553        put(stream, &(name.len() as u16).to_le_bytes(), path)?;
554        put(stream, name, path)?;
555        put(stream, &(meta.st_mode as u32).to_le_bytes(), path)?;
556        put(
557            stream,
558            &self.ids.uid(meta.st_uid, path)?.to_le_bytes(),
559            path,
560        )?;
561        put(
562            stream,
563            &self.ids.gid(meta.st_gid, path)?.to_le_bytes(),
564            path,
565        )?;
566        put(stream, &(meta.st_mtime as i64).to_le_bytes(), path)
567    }
568}
569
570/// The map, read backwards: host id to the id inside the rootfs.
571///
572/// The source tree's ownership is read through the identity map, which is what
573/// makes a copy the exact mirror of an export. An export forks a child into the
574/// map so a file the host stores at `100000 + 42` reads as uid 42, the id the
575/// tree means; a copy takes the id the tree means and stores it back at the
576/// host id the map puts it at. So a tree an earlier mapped sandbox produced
577/// copies into a rootfs owned through the same map with its ownership intact —
578/// which is what a caller wants from a copy and what `cp` running outside the
579/// map cannot do.
580///
581/// Reading the host id as an *inside* id instead would make the common cases
582/// wrong in both directions: a subordinate tree's `100042` is not an id any
583/// rootfs holds, and a caller's own tree could never name a system id at all,
584/// since an unprivileged process cannot create a file owned by one.
585///
586/// The single map falls out of the same rule rather than needing a case of its
587/// own: it maps inside 0 to the calling user, so a caller-owned source lands as
588/// root — what a single-mapped sandbox sees, and what it could have written
589/// itself.
590struct Translation {
591    uid: Vec<IdRange>,
592    gid: Vec<IdRange>,
593}
594
595impl Translation {
596    /// The extents the child's map establishes.
597    fn of(map: &ChildMap<'_>) -> Translation {
598        match map {
599            // The one-id map the child writes itself: the calling user is root
600            // inside and nothing else is mapped.
601            ChildMap::SelfMap { .. } => {
602                let one = |outside| {
603                    vec![IdRange {
604                        inside: 0,
605                        outside,
606                        count: 1,
607                    }]
608                };
609                Translation {
610                    uid: one(rustix::process::geteuid().as_raw()),
611                    gid: one(rustix::process::getegid().as_raw()),
612                }
613            }
614            ChildMap::Delegated { map, .. } => Translation {
615                uid: map.uid().to_vec(),
616                gid: map.gid().to_vec(),
617            },
618        }
619    }
620
621    /// The inside uid a host uid stands for.
622    fn uid(&self, host: u32, path: &Path) -> Result<u32, ProvisionError> {
623        inside(&self.uid, host, "uid", path)
624    }
625
626    /// The inside gid a host gid stands for.
627    fn gid(&self, host: u32, path: &Path) -> Result<u32, ProvisionError> {
628        inside(&self.gid, host, "gid", path)
629    }
630}
631
632/// Finds the inside id `host` falls to, refusing one no extent covers.
633///
634/// A host id outside every extent is one the rootfs cannot hold, and the copy
635/// says so with the path that carries it rather than letting the child fail
636/// with an `EINVAL` naming nothing.
637///
638/// The whole calculation runs in `u64`. An extent is three `u32`s, so both the
639/// containment test and the id it produces can pass `u32::MAX` — a wrapped id in
640/// a release build, a panic in a debug one — and an id is only an id once it
641/// fits back in the width the kernel names it in.
642fn inside(extents: &[IdRange], host: u32, which: &str, path: &Path) -> Result<u32, ProvisionError> {
643    let refuse = |reason: String| {
644        Err(ProvisionError::EntryRefused {
645            path: path.to_path_buf(),
646            reason,
647        })
648    };
649    for extent in extents {
650        let outside = u64::from(extent.outside);
651        let end = outside + u64::from(extent.count);
652        if u64::from(host) >= outside && u64::from(host) < end {
653            let mapped = u64::from(extent.inside) + (u64::from(host) - outside);
654            return match u32::try_from(mapped) {
655                Ok(inside) => Ok(inside),
656                Err(_) => refuse(format!(
657                    "the source entry's {which} {host} maps to {mapped}, which is past the \
658                     largest id the kernel names",
659                )),
660            };
661        }
662    }
663    refuse(format!(
664        "the source entry's {which} {host} is outside the identity map, so the rootfs \
665         has no id for it",
666    ))
667}
668
669/// Opens `path` as a directory, without following a symbolic link at it.
670///
671/// The root of a walk: every directory below it is opened relative to its
672/// parent's descriptor instead. `subject` names the tree in a failure.
673fn open_dir(path: &Path, subject: &Path) -> Result<OwnedFd, ProvisionError> {
674    fs::open(
675        path,
676        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
677        fs::Mode::empty(),
678    )
679    .map_err(|errno| ProvisionError::io("reading", subject, errno.into()))
680}
681
682/// Opens `name` under `dir`, following no symbolic link at it.
683///
684/// `extra` carries `O_DIRECTORY` where a directory is what was typed, so an
685/// entry that has since become something else is refused by the kernel rather
686/// than read as whatever it now is.
687fn openat_nofollow(
688    dir: BorrowedFd<'_>,
689    name: &CStr,
690    extra: OFlags,
691    subject: &Path,
692) -> Result<OwnedFd, ProvisionError> {
693    fs::openat(
694        dir,
695        name,
696        OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | extra,
697        fs::Mode::empty(),
698    )
699    .map_err(|errno| ProvisionError::io("reading", subject, errno.into()))
700}
701
702/// Every entry name `dir` holds, `.` and `..` left out.
703///
704/// Read from a descriptor rather than from a path, so the directory listed is
705/// the directory the walk holds open. `subject` names it in a failure.
706fn read_names(dir: BorrowedFd<'_>, subject: &Path) -> Result<Vec<Vec<u8>>, ProvisionError> {
707    // A `Dir` takes a descriptor of its own and consumes its offset, so this
708    // opens `.` relative to the walk's: the caller goes on resolving every
709    // entry against the descriptor it already holds.
710    let handle = openat_nofollow(dir, c".", OFlags::DIRECTORY, subject)?;
711    let mut names = Vec::new();
712    for entry in fs::Dir::read_from(&handle)
713        .map_err(|errno| ProvisionError::io("reading", subject, errno.into()))?
714    {
715        let entry = entry.map_err(|errno| ProvisionError::io("reading", subject, errno.into()))?;
716        let name = entry.file_name().to_bytes();
717        if name == b"." || name == b".." {
718            continue;
719        }
720        names.push(name.to_vec());
721    }
722    Ok(names)
723}
724
725/// An entry's name as a `CStr`, for the `*at` calls that resolve it.
726///
727/// The component check above has already refused a name carrying a NUL, so this
728/// is the boundary the bytes cross rather than the rule they are held to.
729fn leaf_cstring(name: &[u8], subject: &Path) -> Result<CString, ProvisionError> {
730    CString::new(name).map_err(|_| ProvisionError::EntryRefused {
731        path: subject.to_path_buf(),
732        reason: "the entry name carries a NUL".to_string(),
733    })
734}
735
736/// Streams exactly `size` bytes of `path` to the child, and refuses to call a
737/// file that changed underneath it copied.
738///
739/// The frame's declared length is committed before any data is read, so a file
740/// that grew or shrank in between cannot be sent faithfully: the child is
741/// counting bytes, and a frame that lied about its length would leave every
742/// later frame misaligned. So the frame is finished either way — a short file
743/// padded with zeroes, a grown one cut at the declared length — and only then
744/// is the mismatch reported.
745///
746/// The frame is honored and the copy still fails, because those are answers to
747/// different questions. Keeping the stream in step is what lets the failure be
748/// reported at all rather than as a desynchronized child; it is not a reason to
749/// hand back a rootfs holding a zero-padded binary and call it a copy.
750fn stream_file(
751    stream: BorrowedFd<'_>,
752    file: OwnedFd,
753    path: &Path,
754    size: u64,
755) -> Result<(), ProvisionError> {
756    let mut file = std::fs::File::from(file);
757    let mut buf = [0u8; DATA_BUF];
758    let mut left = size;
759    let mut changed = false;
760    while left > 0 {
761        let want = usize::try_from(left.min(DATA_BUF as u64)).unwrap_or(DATA_BUF);
762        let read = file
763            .read(&mut buf[..want])
764            .map_err(|err| ProvisionError::io("reading", path, err))?;
765        if read == 0 {
766            // The file is shorter than its stat said.
767            changed = true;
768            buf[..want].fill(0);
769            put(stream, &buf[..want], path)?;
770            left -= want as u64;
771            continue;
772        }
773        put(stream, &buf[..read], path)?;
774        left -= read as u64;
775    }
776
777    // One byte past the declared length says whether the file grew. Nothing is
778    // sent: the frame is complete, and this only decides what to report.
779    if !changed {
780        let mut past = [0u8; 1];
781        changed = file
782            .read(&mut past)
783            .map_err(|err| ProvisionError::io("reading", path, err))?
784            != 0;
785    }
786    if changed {
787        return Err(ProvisionError::SourceChanged {
788            path: path.to_path_buf(),
789            recorded: size,
790        });
791    }
792    Ok(())
793}
794
795/// Writes `data` in full to the child's stream, blaming `path` for a failure.
796///
797/// The child is the only thing that can end the write early, and its outcome
798/// record says why; this reports the broken pipe and lets `finish` supply the
799/// reason.
800fn put(stream: BorrowedFd<'_>, data: &[u8], path: &Path) -> Result<(), ProvisionError> {
801    frame::write_full(stream, data)
802        .map_err(|errno| ProvisionError::io("copying", path, errno.into()))
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::scratch::Scratch;
809
810    /// Streams `path` at a declared `size` into a throwaway pipe and reports
811    /// what the copy made of it.
812    ///
813    /// The declared size is what the frame committed, so passing one that does
814    /// not match the file reproduces exactly the race the walk loses — a source
815    /// that changed between the stat and the read — without having to win it.
816    /// The sizes stay well under a pipe's capacity, so nothing has to drain the
817    /// read end.
818    fn stream_at(path: &Path, size: u64) -> Result<(), ProvisionError> {
819        let (read, write) = rustix::pipe::pipe().expect("a pipe is creatable");
820        // Opened as the walk opens one: the descriptor is what the data comes
821        // from, so the file streamed is the file that was typed.
822        let file = std::fs::File::open(path).expect("the fixture opens");
823        let outcome = stream_file(write.as_fd(), OwnedFd::from(file), path, size);
824        drop((read, write));
825        outcome
826    }
827
828    #[test]
829    fn an_entry_is_resolved_against_its_directory_and_never_through_a_symlink() {
830        // The walk types an entry and then reads, opens, or descends into it.
831        // Resolving the name afresh at each step follows whatever is at the
832        // path by then, so an entry swapped for a symlink between the two is
833        // traversed -- a directory descended out of the source, or a file
834        // streamed from wherever the link points. Every step resolves against
835        // the descriptor of the directory holding the name instead, and refuses
836        // a symlink at it.
837        let dir = Scratch::new("copyin-nofollow");
838        std::fs::create_dir_all(dir.join("outside")).unwrap();
839        std::fs::write(dir.join("outside/secret"), b"not the source's").unwrap();
840        std::fs::create_dir_all(dir.join("source")).unwrap();
841        std::fs::write(dir.join("source/own"), b"the source's own").unwrap();
842        std::os::unix::fs::symlink(dir.join("outside"), dir.join("source/as-dir")).unwrap();
843        std::os::unix::fs::symlink(dir.join("outside/secret"), dir.join("source/as-file")).unwrap();
844
845        let source = open_dir(&dir.join("source"), &dir).expect("the source opens");
846        for (name, extra) in [
847            (c"as-dir", OFlags::DIRECTORY),
848            (c"as-file", OFlags::empty()),
849        ] {
850            assert!(
851                openat_nofollow(source.as_fd(), name, extra, &dir).is_err(),
852                "{name:?} was opened through a symlink",
853            );
854        }
855        // What is not a symlink still opens, so the refusal is the link's doing.
856        openat_nofollow(source.as_fd(), c"own", OFlags::empty(), &dir)
857            .expect("an ordinary file opens");
858
859        // And the listing comes from the descriptor: a source directory
860        // replaced by a symlink after it was opened is not what gets listed.
861        let mut names = read_names(source.as_fd(), &dir).expect("the source lists");
862        names.sort();
863        assert_eq!(
864            names,
865            [b"as-dir".to_vec(), b"as-file".to_vec(), b"own".to_vec()]
866        );
867        std::fs::remove_file(dir.join("source/own")).unwrap();
868        std::fs::remove_file(dir.join("source/as-dir")).unwrap();
869        std::fs::remove_file(dir.join("source/as-file")).unwrap();
870        std::fs::remove_dir(dir.join("source")).unwrap();
871        std::os::unix::fs::symlink(dir.join("outside"), dir.join("source")).unwrap();
872        assert_eq!(
873            read_names(source.as_fd(), &dir).expect("the held directory still lists"),
874            Vec::<Vec<u8>>::new(),
875            "the descriptor lists the directory it opened, not the path's new target",
876        );
877    }
878
879    #[test]
880    fn a_file_matching_its_recorded_size_streams_cleanly() {
881        let dir = Scratch::new("copyin-intact");
882        let path = dir.join("file");
883        std::fs::write(&path, b"exactly this").unwrap();
884        stream_at(&path, 12).expect("an unchanged file copies");
885    }
886
887    #[test]
888    fn a_file_shorter_than_its_recorded_size_is_padded_and_refused() {
889        // The padding keeps the frame the length the child is counting on, and
890        // the refusal is what stops a zero-padded binary being reported as a
891        // copy. Both, not either.
892        let dir = Scratch::new("copyin-short");
893        let path = dir.join("file");
894        std::fs::write(&path, b"short").unwrap();
895        match stream_at(&path, 4096) {
896            Err(ProvisionError::SourceChanged {
897                path: changed,
898                recorded,
899            }) => {
900                assert_eq!(changed, path);
901                assert_eq!(recorded, 4096, "the frame's committed length is reported");
902            }
903            other => panic!("expected a source-changed refusal, got {other:?}"),
904        }
905    }
906
907    #[test]
908    fn a_file_longer_than_its_recorded_size_is_refused_too() {
909        // The grown case truncates rather than pads, and was the quieter of the
910        // two: the loop simply stops at the declared length with nothing to
911        // notice. One read past the end is what notices.
912        let dir = Scratch::new("copyin-grown");
913        let path = dir.join("file");
914        std::fs::write(&path, b"much more than was recorded").unwrap();
915        match stream_at(&path, 4) {
916            Err(ProvisionError::SourceChanged { recorded, .. }) => assert_eq!(recorded, 4),
917            other => panic!("expected a source-changed refusal, got {other:?}"),
918        }
919    }
920
921    #[test]
922    fn a_host_id_maps_back_through_the_extent_that_covers_it() {
923        // The ordinary reading: a subordinate tree's 100042 is uid 42 inside,
924        // and a host id no extent covers is one the rootfs has no name for.
925        let extents = [IdRange {
926            inside: 0,
927            outside: 100_000,
928            count: 65_536,
929        }];
930        let path = Path::new("/src/file");
931        assert_eq!(inside(&extents, 100_042, "uid", path).unwrap(), 42);
932        assert_eq!(inside(&extents, 100_000, "uid", path).unwrap(), 0);
933        // The extent is half-open: its last id maps, the one past it does not.
934        assert_eq!(inside(&extents, 165_535, "uid", path).unwrap(), 65_535);
935        let err = inside(&extents, 165_536, "uid", path).unwrap_err();
936        assert!(
937            err.to_string().contains("outside the identity map"),
938            "{err}"
939        );
940    }
941
942    #[test]
943    fn a_mapped_id_that_would_not_fit_a_u32_is_refused_rather_than_wrapped() {
944        // An extent is three `u32`s, so `inside + count` can pass `u32::MAX`.
945        // Computed in `u32` the sum wraps in a release build and panics in a
946        // debug one; computed in `u64` it is simply an id the kernel cannot
947        // name, and the copy says so with the path that carries it.
948        let extents = [IdRange {
949            inside: u32::MAX - 1,
950            outside: 1_000,
951            count: 10,
952        }];
953        let path = Path::new("/src/file");
954        // Still within `u32`: the last id that fits maps.
955        assert_eq!(inside(&extents, 1_001, "uid", path).unwrap(), u32::MAX);
956        let err = inside(&extents, 1_002, "gid", path).unwrap_err();
957        assert!(
958            err.to_string().contains("largest id the kernel names"),
959            "{err}",
960        );
961    }
962
963    #[test]
964    fn an_empty_file_recorded_as_empty_is_not_a_change() {
965        // The zero-length case reads nothing and must not look like a file that
966        // vanished: the check past the end is what tells them apart.
967        let dir = Scratch::new("copyin-empty");
968        let path = dir.join("file");
969        std::fs::write(&path, b"").unwrap();
970        stream_at(&path, 0).expect("an empty file copies");
971    }
972}