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}