ferroday_cage/provision/mod.rs
1//! Rootfs provisioning: producing the root filesystem a sandbox runs in.
2//!
3//! A [`Provisioner`] populates a staging directory with a complete root
4//! filesystem, and [`ensure`] publishes the result atomically: the
5//! destination directory either does not exist or holds a fully provisioned
6//! rootfs, never a partial one, even across crashes and concurrent callers.
7//!
8// The `Tarball` sentence links a feature-gated item, so its doc text is gated
9// on the feature: present in the canonical all-features build, absent — link
10// and all — from a no-feature `cargo doc`.
11#![cfg_attr(
12 feature = "tarball",
13 doc = "
14With the `tarball` feature, [`Tarball`] provisions from a tar archive
15(plain, gzip, xz, or zstd, detected by content), extracting with
16kernel-enforced containment so a hostile archive cannot write outside
17the destination.
18
19"
20)]
21//! # Example
22//!
23//! ```no_run
24//! # #[cfg(feature = "tarball")]
25//! # fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
26//! use ferroday_cage::provision::{self, Tarball};
27//!
28//! let rootfs = "/var/cache/myapp/alpine-rootfs";
29//! provision::ensure(rootfs, &mut Tarball::new("alpine-minirootfs.tar.gz"))?;
30//! # Ok(())
31//! # }
32//! # #[cfg(not(feature = "tarball"))]
33//! # fn main() {}
34//! ```
35
36use std::ffi::OsString;
37use std::fmt;
38use std::fs;
39use std::io;
40use std::path::{Path, PathBuf};
41
42use rustix::fs::{FlockOperation, Mode, OFlags};
43use rustix::io::Errno;
44
45use crate::failure::path_io_error;
46use crate::mechanism::frame;
47
48#[cfg(any(feature = "debian", feature = "alpine"))]
49mod binfmt;
50#[cfg(feature = "tarball")]
51mod compress;
52mod containment;
53#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
54mod coordinate;
55mod copyin;
56#[cfg(feature = "tarball")]
57mod export;
58#[cfg(feature = "tarball")]
59mod extract;
60// The transport seam and the digesting that verifies what it brings back serve
61// every userland provisioner that talks to an archive, so they are compiled for
62// each of them rather than gaining a feature of their own — a feature nothing
63// could enable on purpose would only widen the powerset the `features` job
64// walks. Each such gate names all three layers, and gains the next one the same
65// way.
66#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
67mod digest;
68// The plan document's syntax, which every userland layer's plan is written in
69// -- and, for Gentoo, the syntax its archive publishes its binary-package index
70// in as well.
71#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
72mod document;
73#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
74mod fetch;
75#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
76mod http;
77mod identity;
78// The overlay upper a layered build installs into, which is a disposal handle
79// rather than anything one userland knows about.
80#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
81mod layer;
82// Establishing that a cleartext-signed document is authentic against a keyring,
83// which is a question about the signature rather than about any archive that
84// asks it. Alpine verifies RSA PKCS#1 over its own containers and reaches none
85// of this, so the gate names the two layers whose trust chains are OpenPGP.
86#[cfg(any(feature = "debian", feature = "gentoo"))]
87mod openpgp;
88pub(crate) mod rooted;
89#[cfg(feature = "tarball")]
90mod tar;
91#[cfg(feature = "tarball")]
92mod tarball;
93
94pub use copyin::{CopyIn, CopyReport, SkippedEntry, SkippedKind};
95#[cfg(feature = "tarball")]
96pub use export::{Export, ExportEntry, ExportKind, export_tar};
97#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
98pub(crate) use fetch::{Failover, mirror_url, walk_mirrors};
99#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
100pub use fetch::{Fetch, FetchError, FetchJob, FetchRequest};
101#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
102pub use http::{HttpFetch, file_url};
103#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
104pub use layer::BuildLayer;
105#[cfg(feature = "tarball")]
106pub use tarball::Tarball;
107
108#[cfg(feature = "alpine")]
109pub mod alpine;
110#[cfg(feature = "debian")]
111pub mod debian;
112#[cfg(feature = "gentoo")]
113pub mod gentoo;
114
115/// Something a provisioner is doing, reported to a [`ProvisionObserver`].
116///
117/// Provisioning a root filesystem is the slow part of a first run — extracting
118/// a 250 MB stage3, fetching and configuring a hundred packages — so a
119/// provisioner reports what it is doing as it goes. The variants a given
120/// provisioner emits are its own business; an observer matches the ones it
121/// cares about and ignores the rest, which `#[non_exhaustive]` requires it to
122/// do anyway.
123#[derive(Debug)]
124#[non_exhaustive]
125pub enum ProvisionEvent<'a> {
126 /// An entry is about to be written into the staging tree.
127 ///
128 /// The path is relative to the staging directory, as the source records
129 /// it.
130 #[non_exhaustive]
131 Entry {
132 /// The entry's path within the tree being provisioned.
133 path: &'a Path,
134 },
135 /// Progress through the provisioner's source.
136 ///
137 /// `total` is the source's size when the provisioner knows it — an archive
138 /// read from a file — and `None` when it does not, as for an archive
139 /// streamed from a reader of unknown length.
140 #[non_exhaustive]
141 Read {
142 /// Bytes consumed from the source so far.
143 done: u64,
144 /// The source's total size, when known.
145 total: Option<u64>,
146 },
147 /// A Debian bootstrap's own, richer event.
148 ///
149 /// The bootstrap reports fetches, resolution, downloads, and the output of
150 /// the `dpkg` waves it runs in a cage — detail the shared vocabulary above
151 /// has no place for. Nesting it here keeps one observer for every
152 /// provisioner rather than one per provisioner.
153 #[cfg(feature = "debian")]
154 Debian(&'a debian::DebianEvent<'a>),
155 /// An Alpine bootstrap's own, richer event.
156 ///
157 /// The bootstrap reports fetches, resolution, downloads, and the output of
158 /// the install scripts it runs in a cage — detail the shared vocabulary
159 /// above has no place for. Nesting it here keeps one observer for every
160 /// provisioner rather than one per provisioner.
161 #[cfg(feature = "alpine")]
162 Alpine(&'a alpine::AlpineEvent<'a>),
163 /// A Gentoo bootstrap's own event.
164 ///
165 /// The bootstrap reports the documents it fetches, what it resolved, and
166 /// the verification and extraction of the tarball — detail the shared
167 /// vocabulary above has no place for. Nesting it here keeps one observer for
168 /// every provisioner rather than one per provisioner.
169 #[cfg(feature = "gentoo")]
170 Gentoo(&'a gentoo::GentooEvent<'a>),
171}
172
173/// A standard stream of a command a provisioner runs in a cage.
174///
175/// Every userland layer's own event carries this in its `CommandOutput` variant,
176/// which is why it is here rather than in one of them: a provisioner that runs a
177/// command in a cage has output to attribute to a stream whichever archive it
178/// provisions from, and the attribution says nothing about the archive. The
179/// events themselves stay with their layers, nested in [`ProvisionEvent`] — they
180/// speak each archive's own vocabulary, and this does not.
181#[cfg(any(feature = "debian", feature = "alpine"))]
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
183#[non_exhaustive]
184pub enum Stream {
185 /// Standard output.
186 Stdout,
187 /// Standard error.
188 Stderr,
189}
190
191/// A progress and cancellation sink for a provisioning run.
192///
193/// Attached with [`Provision::observe`], and reached by a provisioner through
194/// [`ProvisionRequest::report`] and [`ProvisionRequest::cancelled`]. Both
195/// methods have a default body, so an observer implements only the half it
196/// wants, and a plain `FnMut(ProvisionEvent<'_>)` closure is an observer that
197/// reports and never cancels.
198///
199/// # Example
200///
201/// ```no_run
202/// # #[cfg(feature = "tarball")]
203/// # fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
204/// use std::sync::Arc;
205/// use std::sync::atomic::{AtomicBool, Ordering};
206///
207/// use ferroday_cage::provision::{
208/// Provision, ProvisionEvent, ProvisionObserver, Tarball,
209/// };
210///
211/// struct Progress {
212/// stop: Arc<AtomicBool>,
213/// }
214///
215/// impl ProvisionObserver for Progress {
216/// fn progress(&mut self, event: ProvisionEvent<'_>) {
217/// if let ProvisionEvent::Read { done, total: Some(total), .. } = event {
218/// eprint!("\r{}%", done * 100 / total.max(1));
219/// }
220/// }
221///
222/// fn cancelled(&mut self) -> bool {
223/// self.stop.load(Ordering::Relaxed)
224/// }
225/// }
226///
227/// let stop = Arc::new(AtomicBool::new(false));
228/// Provision::new("/var/cache/myapp/alpine-rootfs")
229/// .observe(&mut Progress { stop: Arc::clone(&stop) })
230/// .run(&mut Tarball::new("alpine-minirootfs.tar.gz"))?;
231/// # Ok(())
232/// # }
233/// # #[cfg(not(feature = "tarball"))]
234/// # fn main() {}
235/// ```
236pub trait ProvisionObserver {
237 /// Receives one progress event.
238 fn progress(&mut self, event: ProvisionEvent<'_>) {
239 let _ = event;
240 }
241
242 /// Whether the run should stop.
243 ///
244 /// Consulted by a provisioner at the points where stopping is clean — an
245 /// archive entry boundary, a package boundary. Returning `true` aborts the
246 /// run with [`ProvisionError::Cancelled`]; [`ensure`] then removes the
247 /// staging tree, so a cancelled run leaves no destination behind, exactly
248 /// as a failed one does.
249 ///
250 /// The default is `false`: a run that is never cancelled.
251 fn cancelled(&mut self) -> bool {
252 false
253 }
254}
255
256/// A closure is an observer that reports and never cancels.
257impl<F: FnMut(ProvisionEvent<'_>)> ProvisionObserver for F {
258 fn progress(&mut self, event: ProvisionEvent<'_>) {
259 self(event)
260 }
261}
262
263/// The observer as a provisioning run carries it: shared, so the immutable
264/// [`ProvisionRequest`] every provisioner holds can still call into it.
265type ObserverCell<'o> = std::cell::RefCell<&'o mut dyn ProvisionObserver>;
266
267/// The inputs to one provisioning run.
268///
269/// [`Provisioner::provision`] takes its inputs as this value rather than as
270/// loose parameters, so that context a provisioner comes to need can be added
271/// as accessors without changing the trait method's signature.
272///
273/// A provisioner reads what it understands and ignores the rest.
274#[derive(Clone, Copy)]
275pub struct ProvisionRequest<'a> {
276 staging: &'a Path,
277 observer: Option<&'a ObserverCell<'a>>,
278}
279
280impl<'a> ProvisionRequest<'a> {
281 /// Returns a request naming `staging` as the directory to fill, with no
282 /// observer attached.
283 ///
284 /// [`ensure`] and [`Provision::run`] build the request a provisioner
285 /// receives; this constructor is for a caller driving a [`Provisioner`]
286 /// directly.
287 pub fn new(staging: &'a Path) -> ProvisionRequest<'a> {
288 ProvisionRequest {
289 staging,
290 observer: None,
291 }
292 }
293
294 /// The same request with `observer` attached.
295 pub(crate) fn observed(self, observer: &'a ObserverCell<'a>) -> ProvisionRequest<'a> {
296 ProvisionRequest {
297 observer: Some(observer),
298 ..self
299 }
300 }
301
302 /// The directory to fill with the root filesystem's contents.
303 ///
304 /// It exists and is empty. It is not the rootfs's final location:
305 /// [`ensure`] renames it into place once the provisioner returns.
306 pub fn staging(&self) -> &'a Path {
307 self.staging
308 }
309
310 /// Reports an event to the run's observer, if one is attached.
311 ///
312 /// A no-op otherwise, so a provisioner reports unconditionally rather than
313 /// branching on whether anyone is listening.
314 pub fn report(&self, event: ProvisionEvent<'_>) {
315 if let Some(observer) = self.observer {
316 observer.borrow_mut().progress(event);
317 }
318 }
319
320 /// Whether the run has been cancelled, and the provisioner should stop.
321 ///
322 /// `false` when no observer is attached. A provisioner consults this at
323 /// the boundaries where stopping is clean, and returns
324 /// [`ProvisionError::Cancelled`] when it is `true`.
325 pub fn cancelled(&self) -> bool {
326 match self.observer {
327 Some(observer) => observer.borrow_mut().cancelled(),
328 None => false,
329 }
330 }
331}
332
333impl fmt::Debug for ProvisionRequest<'_> {
334 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335 f.debug_struct("ProvisionRequest")
336 .field("staging", &self.staging)
337 .field("observed", &self.observer.is_some())
338 .finish_non_exhaustive()
339 }
340}
341
342/// Populates a staging directory with a complete root filesystem.
343///
344/// A provisioner is handed an existing, empty staging directory and fills
345/// it; it must not assume the directory is the rootfs's final location.
346/// [`ensure`] supplies the staging directory and publishes it atomically.
347///
348/// On error, the provisioner may leave the staging directory in any state;
349/// the caller removes it.
350///
351/// The method takes `&mut self`, so a provisioner may carry mutable state
352/// across the call — a fetcher's connections, a progress sink — and is
353/// consumed by [`ensure`] through a `&mut` reference.
354///
355/// # Stability
356///
357/// Every method added to this trait in a later release will carry a default
358/// body, so an existing implementation keeps compiling. New inputs arrive as
359/// accessors on [`ProvisionRequest`] rather than as parameters, for the same
360/// reason.
361///
362/// # Example
363///
364/// ```
365/// use std::fs;
366///
367/// use ferroday_cage::provision::{ProvisionError, ProvisionRequest, Provisioner};
368///
369/// /// A provisioner that lays down a single marker file.
370/// struct Marker;
371///
372/// impl Provisioner for Marker {
373/// fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
374/// let path = request.staging().join("marker");
375/// fs::write(&path, b"provisioned")
376/// .map_err(|err| ProvisionError::io("writing", path, err))
377/// }
378/// }
379/// ```
380pub trait Provisioner {
381 /// Fills the request's staging directory with the root filesystem's
382 /// contents.
383 fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError>;
384}
385
386/// The outcome of [`ensure`]: whether the rootfs was created by this call
387/// or was already present.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
389#[non_exhaustive]
390pub enum Provisioned {
391 /// The rootfs did not exist and was provisioned and published.
392 Created,
393 /// The rootfs already existed; the provisioner did not run.
394 Existing,
395}
396
397/// An error from provisioning a rootfs.
398///
399/// Provisioning is a separate act from launching a sandbox, and carries its
400/// own error type; it never appears inside [`Error`](crate::Error).
401#[derive(Debug)]
402#[non_exhaustive]
403pub enum ProvisionError {
404 /// The destination path cannot host a rootfs directory.
405 ///
406 /// The path has no final component, or something that is not a
407 /// directory already exists there.
408 #[non_exhaustive]
409 DestUnusable {
410 /// The destination as it was given.
411 path: PathBuf,
412 },
413 /// A host I/O operation failed.
414 #[non_exhaustive]
415 Io {
416 /// What the operation was doing.
417 op: &'static str,
418 /// The path the operation concerned.
419 path: PathBuf,
420 /// The underlying I/O error.
421 source: io::Error,
422 },
423 /// The archive is malformed or truncated.
424 #[non_exhaustive]
425 Archive {
426 /// The archive offset of the offending block, in bytes of the
427 /// uncompressed stream.
428 offset: u64,
429 /// What was wrong.
430 reason: String,
431 },
432 /// An archive entry attempted to reach outside the extraction root.
433 ///
434 /// The entry's path or link target is absolute, contains `..`, carries a
435 /// NUL byte that would truncate it, or resolves through a symlink to a
436 /// location outside the rootfs being extracted. Containment is enforced by
437 /// the kernel during path resolution, not by inspecting the path text.
438 ///
439 /// This variant asserts a containment failure and nothing else. An entry
440 /// refused for any other reason is [`EntryRefused`](Self::EntryRefused).
441 #[non_exhaustive]
442 EntryUnsafe {
443 /// The entry's path as recorded in the archive.
444 path: PathBuf,
445 /// What was unsafe about it.
446 reason: String,
447 },
448 /// An entry cannot be handled as it stands, for a reason that is not a
449 /// containment failure.
450 ///
451 /// A malformed archive — a path component that is not a directory, an empty
452 /// symlink target — a source tree past a limit the copy protocol can carry
453 /// (its depth, an entry name, a link target), or a source id the
454 /// destination's identity map has no place for.
455 ///
456 /// Distinct from [`EntryUnsafe`](Self::EntryUnsafe), which means an entry
457 /// tried to reach outside the tree it was being written into. Nothing here
458 /// did.
459 #[non_exhaustive]
460 EntryRefused {
461 /// The entry's path: as recorded in the archive, or as it sits in the
462 /// source tree.
463 path: PathBuf,
464 /// Why it was refused.
465 reason: String,
466 },
467 /// An archive entry has a type the extractor does not support.
468 ///
469 /// Sparse and multi-volume entries are not supported. (Character and
470 /// block devices and GNU volume labels are not errors; they are skipped,
471 /// as documented on the `Tarball` provisioner.)
472 #[non_exhaustive]
473 EntryUnsupported {
474 /// The entry's path as recorded in the archive.
475 path: PathBuf,
476 /// The entry's tar typeflag byte.
477 kind: u8,
478 },
479 /// The file is neither a recognized compression format nor a tar
480 /// archive.
481 #[non_exhaustive]
482 FormatUnrecognized {
483 /// The file as it was given.
484 path: PathBuf,
485 },
486 /// The run was cancelled through the observer.
487 ///
488 /// Reported by a provisioner whose [`ProvisionRequest::cancelled`] check
489 /// answered `true`. [`ensure`] removes the staging tree, so the
490 /// destination is left absent, as it is for any other failed run.
491 Cancelled,
492 /// A failure from a provisioner outside this crate.
493 ///
494 /// The stable channel through which a provisioner implemented elsewhere
495 /// reports its own error type; build one with [`ProvisionError::other`].
496 #[non_exhaustive]
497 Other {
498 /// The provisioner's own error.
499 source: Box<dyn std::error::Error + Send + Sync>,
500 },
501 /// A tree could not be removed: it holds files owned through an
502 /// identity map, and no delegate can establish that map to act on them.
503 ///
504 /// A range-mapped sandbox writes real ownership, so its rootfs can hold
505 /// files the plain calling user cannot delete. [`remove`] escalates by
506 /// re-entering the same map; this error is the escalation finding no
507 /// delegate, with the reason naming what the host is missing.
508 #[non_exhaustive]
509 RemoveUnprivileged {
510 /// The tree that could not be removed.
511 path: PathBuf,
512 /// The removal failure that triggered the escalation.
513 source: io::Error,
514 /// Why no delegate could establish the identity map.
515 reason: String,
516 },
517 /// A tree could not be exported: it holds files owned through a range
518 /// identity map, and no delegate can establish that map to read them at
519 /// their intended ownership.
520 ///
521 /// [`export_tar`] re-enters the map the tree was built under so a file the
522 /// rootfs means as a system id is read as that id, not the host
523 /// subordinate id it is stored as. This error is that re-entry finding no
524 /// delegate for a range map, with the reason naming what the host is
525 /// missing.
526 #[cfg(feature = "tarball")]
527 #[non_exhaustive]
528 ExportUnprivileged {
529 /// The tree that could not be exported.
530 path: PathBuf,
531 /// Why no delegate could establish the identity map.
532 reason: String,
533 },
534 /// An export's destination file lies inside the tree being exported.
535 ///
536 /// The archive would be a member of its own source: the walk reaches the
537 /// file it is being written to, and what lands in the archive depends on
538 /// how far the encoder had got when the walk arrived there. Reported by
539 /// [`Export::write_to_path`], which names the destination and so can see
540 /// it; [`Export::write_to`] takes a sink it cannot locate, and a caller
541 /// that opens a file inside the tree itself gets the archive it asked for.
542 #[cfg(feature = "tarball")]
543 #[non_exhaustive]
544 ExportDestInside {
545 /// The destination as it was given.
546 path: PathBuf,
547 /// The tree it lies inside.
548 rootfs: PathBuf,
549 },
550 /// A copy into a rootfs owned through a range map could not establish that
551 /// map.
552 ///
553 /// Creating an entry owned by a mapped id needs a process inside the map,
554 /// and no delegate could establish one.
555 // The sentence links a feature-gated variant, so its doc text is gated on
556 // the feature: present in the canonical all-features build, absent — link
557 // and all — from a no-feature `cargo doc`.
558 #[cfg_attr(
559 feature = "tarball",
560 doc = "
561It is the mirror of [`ExportUnprivileged`](Self::ExportUnprivileged),
562which arises for the same reason.
563"
564 )]
565 #[non_exhaustive]
566 CopyUnprivileged {
567 /// The rootfs that could not be copied into.
568 path: PathBuf,
569 /// Why no delegate could establish the identity map.
570 reason: String,
571 },
572 /// A source file changed size while a [`CopyIn`] was reading it, so the
573 /// copy could not reproduce it.
574 ///
575 /// The copy commits a file's length before it reads the contents, and the
576 /// in-map writer reads exactly that many bytes. A file that grew or shrank
577 /// in between is therefore written truncated or zero-padded, and the run
578 /// reports this rather than presenting the result as a copy: a source tree
579 /// changing underneath a copy is something the caller would rather hear
580 /// about than discover in the rootfs later.
581 #[non_exhaustive]
582 SourceChanged {
583 /// The source file that changed.
584 path: PathBuf,
585 /// The length the copy recorded and wrote.
586 recorded: u64,
587 },
588}
589
590impl ProvisionError {
591 /// Wraps a provisioner's own error.
592 ///
593 /// The channel for a [`Provisioner`] implemented outside this crate: its
594 /// error type travels intact and is reachable through
595 /// [`source`](std::error::Error::source), so a consumer can downcast to it.
596 ///
597 /// # Example
598 ///
599 /// ```
600 /// use ferroday_cage::provision::ProvisionError;
601 ///
602 /// let err = ProvisionError::other("the mirror rejected the request");
603 /// assert!(err.to_string().contains("the mirror rejected the request"));
604 /// ```
605 pub fn other(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> ProvisionError {
606 ProvisionError::Other {
607 source: source.into(),
608 }
609 }
610}
611
612path_io_error! {
613 ProvisionError,
614 /// ```
615 /// use std::fs;
616 ///
617 /// use ferroday_cage::provision::ProvisionError;
618 ///
619 /// # fn read(path: &std::path::Path) -> Result<Vec<u8>, ProvisionError> {
620 /// fs::read(path).map_err(ProvisionError::at("reading", path))
621 /// # }
622 /// ```
623}
624
625impl fmt::Display for ProvisionError {
626 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
627 match self {
628 ProvisionError::DestUnusable { path } => write!(
629 f,
630 "cannot publish a rootfs at {}: the path has no final component or is not a directory",
631 path.display(),
632 ),
633 ProvisionError::Io { op, path, source } => {
634 write!(
635 f,
636 "provisioning failed while {op} {}: {source}",
637 path.display()
638 )
639 }
640 ProvisionError::Archive { offset, reason } => {
641 write!(f, "malformed archive at offset {offset}: {reason}")
642 }
643 ProvisionError::EntryUnsafe { path, reason } => write!(
644 f,
645 "the archive entry {} reaches outside the extraction root: {reason}",
646 path.display(),
647 ),
648 ProvisionError::EntryRefused { path, reason } => {
649 write!(f, "the entry {} was refused: {reason}", path.display())
650 }
651 ProvisionError::EntryUnsupported { path, kind } => write!(
652 f,
653 "the archive entry {} has unsupported type {:?}",
654 path.display(),
655 char::from(*kind),
656 ),
657 ProvisionError::FormatUnrecognized { path } => write!(
658 f,
659 "{} is neither a recognized compression format nor a tar archive",
660 path.display(),
661 ),
662 ProvisionError::Cancelled => f.write_str("provisioning was cancelled"),
663 ProvisionError::Other { source } => write!(f, "provisioning failed: {source}"),
664 ProvisionError::RemoveUnprivileged {
665 path,
666 source,
667 reason,
668 } => write!(
669 f,
670 "cannot remove {}: {source}; the tree holds ids the caller cannot act on, \
671 and no delegate can establish the identity map to remove them ({reason})",
672 path.display(),
673 ),
674 ProvisionError::CopyUnprivileged { path, reason } => write!(
675 f,
676 "cannot copy into {}: the rootfs is owned through a range identity map, \
677 and no delegate can establish the map to write it ({reason})",
678 path.display(),
679 ),
680 ProvisionError::SourceChanged { path, recorded } => write!(
681 f,
682 "{} changed size while it was being copied: {recorded} bytes were recorded \
683 and written, which is no longer what the file holds",
684 path.display(),
685 ),
686 #[cfg(feature = "tarball")]
687 ProvisionError::ExportUnprivileged { path, reason } => write!(
688 f,
689 "cannot export {}: the tree holds ids owned through a range identity map, \
690 and no delegate can establish the map to read them ({reason})",
691 path.display(),
692 ),
693 #[cfg(feature = "tarball")]
694 ProvisionError::ExportDestInside { path, rootfs } => write!(
695 f,
696 "cannot write the archive to {}: it lies inside {}, the tree being exported, \
697 so the archive would be a member of itself",
698 path.display(),
699 rootfs.display(),
700 ),
701 }
702 }
703}
704
705impl std::error::Error for ProvisionError {
706 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
707 match self {
708 ProvisionError::Io { source, .. } => Some(source),
709 ProvisionError::Other { source } => Some(source.as_ref()),
710 ProvisionError::RemoveUnprivileged { source, .. } => Some(source),
711 _ => None,
712 }
713 }
714}
715
716/// A rootfs publication, configured then run.
717///
718/// The extensible form of [`ensure`]: it takes the destination, a progress and
719/// cancellation observer, and future options — a lock-free mode, a staleness
720/// predicate — as methods here rather than as parameters of a free function.
721///
722/// # Example
723///
724/// ```no_run
725/// # #[cfg(feature = "tarball")]
726/// # fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
727/// use ferroday_cage::provision::{Provision, ProvisionEvent, Tarball};
728///
729/// Provision::new("/var/cache/myapp/alpine-rootfs")
730/// .observe(&mut |event: ProvisionEvent<'_>| eprintln!("{event:?}"))
731/// .run(&mut Tarball::new("alpine-minirootfs.tar.gz"))?;
732/// # Ok(())
733/// # }
734/// # #[cfg(not(feature = "tarball"))]
735/// # fn main() {}
736/// ```
737pub struct Provision<'obs> {
738 dest: PathBuf,
739 observer: Option<&'obs mut dyn ProvisionObserver>,
740}
741
742/// A delegate rendered by what it stands for, for the `Debug` impls of the
743/// builders that hold one.
744///
745/// A fetcher, an observer, and an identity mapper are all supplied by the
746/// caller as code behind a trait object. What a reader of a rendering wants
747/// from one is whether it is there at all, so it renders as `<dyn Fetch>`
748/// rather than as its contents, and an optional one as `Some(<dyn IdMapper>)`
749/// or `None`.
750///
751/// [`IdMapper`](crate::IdMapper) does require `Debug`, so a mapper alone could
752/// render its own. It does not, for two reasons: `Fetch` and
753/// [`ProvisionObserver`] carry no such bound, so one delegate rendering unlike
754/// the other two would be an inconsistency with nothing behind it, and a
755/// caller's mapper is free to render at any length in a place that should stay
756/// compact.
757pub(crate) struct Delegate(pub(crate) &'static str);
758
759impl fmt::Debug for Delegate {
760 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761 write!(f, "<{}>", self.0)
762 }
763}
764
765impl fmt::Debug for Provision<'_> {
766 /// Renders the destination and whether an observer is attached. The
767 /// observer itself is a caller's trait object, with nothing useful to show.
768 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
769 f.debug_struct("Provision")
770 .field("dest", &self.dest)
771 .field(
772 "observer",
773 &self
774 .observer
775 .as_ref()
776 .map(|_| Delegate("dyn ProvisionObserver")),
777 )
778 .finish()
779 }
780}
781
782impl<'obs> Provision<'obs> {
783 /// Returns a publication targeting `dest`.
784 pub fn new(dest: impl AsRef<Path>) -> Provision<'obs> {
785 Provision {
786 dest: dest.as_ref().to_path_buf(),
787 observer: None,
788 }
789 }
790
791 /// Attaches a progress and cancellation observer to the run.
792 ///
793 /// The observer reaches the provisioner as
794 /// [`ProvisionRequest::report`] and [`ProvisionRequest::cancelled`], so
795 /// every provisioner speaks to it the same way. A closure taking a
796 /// [`ProvisionEvent`] is an observer that reports and never cancels.
797 pub fn observe(mut self, observer: &'obs mut dyn ProvisionObserver) -> Provision<'obs> {
798 self.observer = Some(observer);
799 self
800 }
801
802 /// Publishes the rootfs, running `provisioner` if it is absent.
803 ///
804 /// # Errors
805 ///
806 /// As [`ensure`], plus [`ProvisionError::Cancelled`] when the observer
807 /// stops the run.
808 pub fn run(self, provisioner: &mut dyn Provisioner) -> Result<Provisioned, ProvisionError> {
809 let Provision { dest, observer } = self;
810 // Reborrowed explicitly, so the trait object's lifetime shortens to
811 // this frame rather than staying pinned to the builder's.
812 let mut observer = observer;
813 let reborrowed: Option<&mut (dyn ProvisionObserver + '_)> = match &mut observer {
814 Some(observer) => Some(&mut **observer),
815 None => None,
816 };
817 ensure_inner(&dest, provisioner, reborrowed)
818 }
819}
820
821/// A rootfs removal, configured then run.
822///
823/// The extensible form of [`remove`], and the one that names a caller-supplied
824/// identity-map delegate.
825///
826/// # Example
827///
828/// ```no_run
829/// # fn main() -> Result<(), ferroday_cage::provision::ProvisionError> {
830/// use ferroday_cage::IdentityMap;
831/// use ferroday_cage::provision::Remove;
832///
833/// Remove::new("/var/lib/myapp/rootfs")
834/// .map(IdentityMap::Subordinate)
835/// .run()?;
836/// # Ok(())
837/// # }
838/// ```
839pub struct Remove<'a> {
840 dest: PathBuf,
841 map: crate::IdentityMap,
842 mapper: Option<&'a dyn crate::idmap::IdMapper>,
843 remove_lock: bool,
844}
845
846impl fmt::Debug for Remove<'_> {
847 /// Renders the tree, the map it was written under, whether a
848 /// caller-supplied mapper was given, and whether the publication lock is
849 /// removed with the tree. The mapper renders by its presence rather than
850 /// its contents.
851 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
852 f.debug_struct("Remove")
853 .field("dest", &self.dest)
854 .field("map", &self.map)
855 .field(
856 "mapper",
857 &self.mapper.as_ref().map(|_| Delegate("dyn IdMapper")),
858 )
859 .field("remove_lock", &self.remove_lock)
860 .finish()
861 }
862}
863
864impl<'a> Remove<'a> {
865 /// Returns a removal of the tree at `dest`.
866 ///
867 /// The identity map defaults to
868 /// [`IdentityMap::Subordinate`](crate::IdentityMap::Subordinate), which is
869 /// what a tree written by a bundled-delegate sandbox needs.
870 ///
871 /// The publication lock beside `dest` is removed with the tree, matching
872 /// [`remove`]; [`remove_lock(false)`](Self::remove_lock) is how a caller
873 /// whose `dest` is not itself a published rootfs declines that.
874 pub fn new(dest: impl AsRef<Path>) -> Remove<'a> {
875 Remove {
876 dest: dest.as_ref().to_path_buf(),
877 map: crate::IdentityMap::Subordinate,
878 mapper: None,
879 remove_lock: true,
880 }
881 }
882
883 /// Sets the identity map the tree was written under.
884 pub fn map(mut self, map: crate::IdentityMap) -> Remove<'a> {
885 self.map = map;
886 self
887 }
888
889 /// Removes through a caller-supplied identity-map delegate.
890 ///
891 /// Without one, the escalation resolves the bundled delegate chain. A tree
892 /// whose ownership a site-specific mapper established — one allocating ids
893 /// the bundled delegates would not — must be removed through that same
894 /// mapper, so it can re-enter the map the tree was written under. The
895 /// mapper and the [`map`](Self::map) must be the pair the tree was built
896 /// under.
897 pub fn mapper(mut self, mapper: &'a dyn crate::idmap::IdMapper) -> Remove<'a> {
898 self.mapper = Some(mapper);
899 self
900 }
901
902 /// Sets whether the `<dest>.lock` sibling goes with the tree.
903 ///
904 /// Defaults to `true`, because [`ensure`] wrote that file and a published
905 /// rootfs and its lock round-trip together. Pass `false` for a `dest` that
906 /// merely *contains* published rootfs trees rather than being one — a
907 /// scratch directory, a cache, a work area holding several. There
908 /// `<dest>.lock` is an unrelated path that the caller never named as a
909 /// target, and removing it is a deletion outside the tree the caller asked
910 /// about.
911 ///
912 /// The distinction is the caller's to make, because only the caller knows
913 /// which of the two its `dest` is: both are directories, and a containing
914 /// directory that happens to have no `.lock` sibling today is
915 /// indistinguishable from a published one whose lock was already cleared.
916 ///
917 /// The tree itself is removed identically either way; this governs the
918 /// sibling file only.
919 pub fn remove_lock(mut self, remove: bool) -> Remove<'a> {
920 self.remove_lock = remove;
921 self
922 }
923
924 /// Removes the tree.
925 ///
926 /// # Errors
927 ///
928 /// As [`remove`].
929 pub fn run(self) -> Result<(), ProvisionError> {
930 remove_dest(&self.dest, &self.map, self.mapper, self.remove_lock)
931 }
932}
933
934/// Publishes a rootfs at `dest` atomically, provisioning it if absent.
935///
936/// The shorthand for [`Provision`], carrying only the inputs every publication
937/// needs.
938///
939/// If `dest` already exists as a directory, it is taken as the previously
940/// published rootfs and returned as [`Provisioned::Existing`] without
941/// running the provisioner. Otherwise the provisioner fills a staging
942/// directory beside `dest`, which is then renamed into place: `dest` only
943/// ever appears complete, and an interrupted run cannot leave a
944/// half-populated tree that a later caller would trust.
945///
946/// Concurrent calls — including from other processes — serialize on a
947/// `<name>.lock` file beside `dest`; exactly one provisions and the rest
948/// observe the published result. The lock file persists after the call, so a
949/// later publication of the same destination serializes on the same inode;
950/// [`remove`] deletes it along with the rootfs, and so also clears the one a
951/// failed provision leaves beside no rootfs at all. Locking uses `flock`, so
952/// `dest` should live on a local filesystem.
953///
954/// The lock, staging directory, and rename all operate on paths beside
955/// `dest`, so `dest` must live under a directory the caller controls — not a
956/// world-writable one such as a shared `/tmp`, where another user could
957/// pre-plant those paths. The lock open refuses to follow a planted symlink,
958/// and the staging cleanup removes a planted symlink rather than following it
959/// into a removal of its target, but the parent's own path components are
960/// trusted to be the caller's.
961///
962/// Before the rename, the staging tree's filesystem is synced, so a crash
963/// after publication cannot leave truncated files behind the completed
964/// rename.
965///
966/// # Errors
967///
968/// Returns [`ProvisionError::DestUnusable`] when `dest` cannot host a
969/// rootfs directory, any error of the provisioner itself, and
970/// [`ProvisionError::Io`] for failures of the publication machinery. When
971/// the provisioner fails, the staging directory is removed and `dest` is
972/// left absent.
973///
974/// The two cleanups — the failed run's staging tree, and a previous run's stale
975/// one — escalate with [`IdentityMap::Subordinate`](crate::IdentityMap::Subordinate)
976/// and the bundled delegate chain, since nothing here names a map. A tree
977/// provisioned under a caller-supplied mapper, or under an explicit
978/// [`IdentityMap::Ranges`](crate::IdentityMap::Ranges) the bundled chain cannot
979/// reproduce, therefore surfaces as
980/// [`ProvisionError::RemoveUnprivileged`] rather than being cleaned; remove it
981/// with [`Remove`] and the same map and delegate that wrote it.
982pub fn ensure(
983 dest: impl AsRef<Path>,
984 provisioner: &mut dyn Provisioner,
985) -> Result<Provisioned, ProvisionError> {
986 ensure_inner(dest.as_ref(), provisioner, None)
987}
988
989/// The body of [`ensure`] and [`Provision::run`].
990fn ensure_inner(
991 dest: &Path,
992 provisioner: &mut dyn Provisioner,
993 observer: Option<&mut dyn ProvisionObserver>,
994) -> Result<Provisioned, ProvisionError> {
995 // Fast path: a published rootfs needs no lock.
996 match fs::symlink_metadata(dest) {
997 Ok(meta) if meta.is_dir() => return Ok(Provisioned::Existing),
998 Ok(_) => {
999 return Err(ProvisionError::DestUnusable {
1000 path: dest.to_path_buf(),
1001 });
1002 }
1003 Err(_) => {}
1004 }
1005
1006 let Some(name) = dest.file_name() else {
1007 return Err(ProvisionError::DestUnusable {
1008 path: dest.to_path_buf(),
1009 });
1010 };
1011 let parent = match dest.parent() {
1012 Some(parent) if !parent.as_os_str().is_empty() => parent,
1013 _ => Path::new("."),
1014 };
1015 fs::create_dir_all(parent).map_err(|err| ProvisionError::io("creating", parent, err))?;
1016
1017 let mut lock_name = name.to_os_string();
1018 lock_name.push(".lock");
1019 let lock_path = parent.join(lock_name);
1020 // NOFOLLOW so a symlink pre-planted at the lock path (in a world-writable
1021 // parent) cannot redirect the lock, and with it a later write, elsewhere.
1022 // CLOEXEC so the helpers a provisioning run executes — an identity-map
1023 // delegate's `newuidmap`, say — do not inherit the descriptor and hold the
1024 // lock open past their own lifetimes.
1025 let lock = rustix::fs::open(
1026 &lock_path,
1027 OFlags::CREATE | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
1028 Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::ROTH,
1029 )
1030 .map_err(|errno| ProvisionError::io("creating", &lock_path, errno.into()))?;
1031 frame::retry_on_intr!(rustix::fs::flock(&lock, FlockOperation::LockExclusive))
1032 .map_err(|errno| ProvisionError::io("locking", &lock_path, errno.into()))?;
1033
1034 // Re-check under the lock: another caller may have finished the work
1035 // while this one waited.
1036 match fs::symlink_metadata(dest) {
1037 Ok(meta) if meta.is_dir() => return Ok(Provisioned::Existing),
1038 Ok(_) => {
1039 return Err(ProvisionError::DestUnusable {
1040 path: dest.to_path_buf(),
1041 });
1042 }
1043 Err(_) => {}
1044 }
1045
1046 let mut staging_name = OsString::from(".");
1047 staging_name.push(name);
1048 staging_name.push(".staging");
1049 let staging = parent.join(staging_name);
1050
1051 // A leftover staging tree is a crashed prior run; the lock guarantees
1052 // no live owner. The removal escalates through the identity map, since
1053 // the crashed run may have been range-mapped and left subordinate-owned
1054 // files. Inspect without following symlinks: a symlink pre-planted at the
1055 // staging path (in a world-writable parent) is removed as a link, never
1056 // followed into a removal of whatever it points at.
1057 match fs::symlink_metadata(&staging) {
1058 Ok(meta) if meta.is_symlink() => {
1059 fs::remove_file(&staging)
1060 .map_err(|err| ProvisionError::io("removing", &staging, err))?;
1061 }
1062 Ok(_) => remove_tree(&staging, &crate::IdentityMap::Subordinate, None)?,
1063 Err(_) => {}
1064 }
1065 fs::create_dir(&staging).map_err(|err| ProvisionError::io("creating", &staging, err))?;
1066
1067 // The observer is wrapped in a cell local to this frame, so its lifetime
1068 // and the staging path's unify — which `RefCell`'s invariance needs.
1069 let outcome = match observer {
1070 Some(observer) => {
1071 let cell = ObserverCell::new(observer);
1072 provisioner.provision(&ProvisionRequest::new(&staging).observed(&cell))
1073 }
1074 None => provisioner.provision(&ProvisionRequest::new(&staging)),
1075 };
1076 if let Err(err) = outcome {
1077 let _ = remove_tree(&staging, &crate::IdentityMap::Subordinate, None);
1078 return Err(err);
1079 }
1080
1081 // Sync the staging tree's filesystem before the rename, so the rename
1082 // cannot become durable ahead of the data it publishes.
1083 let staging_dir =
1084 fs::File::open(&staging).map_err(|err| ProvisionError::io("opening", &staging, err))?;
1085 rustix::fs::syncfs(&staging_dir)
1086 .map_err(|errno| ProvisionError::io("syncing", &staging, errno.into()))?;
1087 drop(staging_dir);
1088
1089 fs::rename(&staging, dest).map_err(|err| ProvisionError::io("publishing", dest, err))?;
1090
1091 let parent_dir =
1092 fs::File::open(parent).map_err(|err| ProvisionError::io("opening", parent, err))?;
1093 parent_dir
1094 .sync_all()
1095 .map_err(|err| ProvisionError::io("syncing", parent, err))?;
1096
1097 Ok(Provisioned::Created)
1098}
1099
1100/// Removes a rootfs directory, including one a range-mapped sandbox has
1101/// written.
1102///
1103/// The shorthand for [`Remove`], carrying only the inputs every removal needs.
1104///
1105/// A sandbox under a [range identity map](crate::IdentityMap) writes real
1106/// ownership: files it chowned to non-root ids are owned by subordinate ids
1107/// on the host, which a plain `remove_dir_all` cannot delete. This removal
1108/// starts plain and, when it is refused, re-enters the same map — a forked
1109/// process unshares a user namespace, the bundled delegates establish the
1110/// subordinate map for it, and the tree is deleted from inside, where the
1111/// ids are the caller's own. The rootless container runtimes' `unshare rm`
1112/// pattern, as a library call.
1113///
1114/// Removing a path that does not exist is not an error, so the call is
1115/// idempotent; a path that is not a directory is removed as the file it is.
1116///
1117/// The `<name>.lock` file [`ensure`] leaves beside the destination goes with
1118/// it, so create and destroy round-trip and nothing is left behind. A
1119/// destination that was never published through `ensure` has no lock to
1120/// remove, and a removal of a destination whose provision failed clears the
1121/// lock that was left beside no rootfs. A caller whose destination *contains*
1122/// published trees rather than being one wants
1123/// [`Remove::remove_lock(false)`](Remove::remove_lock) instead, so the sibling
1124/// of a path it never published stays where it is.
1125///
1126/// # Errors
1127///
1128/// [`ProvisionError::Io`] for an ordinary removal failure, and
1129/// [`ProvisionError::RemoveUnprivileged`] when the tree needs the identity
1130/// map and no delegate can establish one — the reason names what the host
1131/// is missing.
1132///
1133/// The escalation resolves the bundled delegate chain for the subordinate
1134/// map, which covers a tree written under
1135/// [`IdentityMap::Subordinate`](crate::IdentityMap::Subordinate) or a
1136/// bundled-delegate [`IdentityMap::Ranges`](crate::IdentityMap::Ranges). For a
1137/// tree built under a caller-supplied delegate, use [`Remove::mapper`].
1138pub fn remove(dest: impl AsRef<Path>) -> Result<(), ProvisionError> {
1139 remove_dest(dest.as_ref(), &crate::IdentityMap::Subordinate, None, true)
1140}
1141
1142/// The shared body of [`remove`] and [`Remove::run`]: dispatch a non-directory
1143/// or missing path, then remove a directory tree with the given resolution,
1144/// and finally the publication lock [`ensure`] left beside it.
1145///
1146/// `lock` says whether that last step runs. [`Remove::remove_lock`] clears it,
1147/// for a `dest` that holds published trees rather than being one.
1148fn remove_dest(
1149 dest: &Path,
1150 map: &crate::IdentityMap,
1151 mapper: Option<&dyn crate::idmap::IdMapper>,
1152 lock: bool,
1153) -> Result<(), ProvisionError> {
1154 match fs::symlink_metadata(dest) {
1155 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
1156 Err(err) => return Err(ProvisionError::io("inspecting", dest, err)),
1157 Ok(meta) if !meta.is_dir() => {
1158 fs::remove_file(dest).map_err(|err| ProvisionError::io("removing", dest, err))?;
1159 }
1160 Ok(_) => remove_tree(dest, map, mapper)?,
1161 }
1162 if lock { remove_lock(dest) } else { Ok(()) }
1163}
1164
1165/// Removes the `<name>.lock` file [`ensure`] leaves beside a published rootfs,
1166/// so create and destroy round-trip and a failed provision leaves no orphan.
1167///
1168/// Removed after the tree, so a failure to delete the rootfs leaves the lock
1169/// guarding it. A destination that was never published through [`ensure`] has
1170/// no lock, which is not an error — but neither is that a way to tell a
1171/// published destination from a containing one, which is why
1172/// [`Remove::remove_lock`] is a caller-supplied flag rather than this probing
1173/// for it.
1174///
1175/// Removing the lock is safe only because a removal and a concurrent
1176/// publication of the same destination are already mutually exclusive: a caller
1177/// that deletes a rootfs another thread or process is provisioning has no
1178/// defined outcome, lock or no lock.
1179fn remove_lock(dest: &Path) -> Result<(), ProvisionError> {
1180 let (Some(name), Some(parent)) = (dest.file_name(), dest.parent()) else {
1181 return Ok(());
1182 };
1183 let parent = if parent.as_os_str().is_empty() {
1184 Path::new(".")
1185 } else {
1186 parent
1187 };
1188 let mut lock_name = name.to_os_string();
1189 lock_name.push(".lock");
1190 let lock_path = parent.join(lock_name);
1191 match fs::remove_file(&lock_path) {
1192 Ok(()) => Ok(()),
1193 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1194 Err(err) => Err(ProvisionError::io("removing", &lock_path, err)),
1195 }
1196}
1197
1198/// Removes a directory tree: plain removal first, escalated through the
1199/// identity map when ownership refuses the plain path.
1200///
1201/// The escalation re-enters `map`: a range map through `mapper` when one is
1202/// supplied and the bundled delegate chain otherwise, and a single-identity
1203/// map written by the removal child itself. The single map is worth
1204/// re-entering even though its ids are the caller's own already, because the
1205/// child holds `CAP_DAC_OVERRIDE` and `CAP_FOWNER` over them there: a
1206/// directory whose own mode denies the search or write the deletion needs
1207/// refuses the plain path and yields to the mapped one.
1208///
1209/// Also used by [`ensure`]'s staging cleanup, so a crashed provisioning run
1210/// cannot wedge the destination.
1211fn remove_tree(
1212 dest: &Path,
1213 map: &crate::IdentityMap,
1214 mapper: Option<&dyn crate::idmap::IdMapper>,
1215) -> Result<(), ProvisionError> {
1216 use std::os::unix::ffi::OsStrExt;
1217
1218 let denied = match fs::remove_dir_all(dest) {
1219 Ok(()) => return Ok(()),
1220 // EACCES and EPERM are the ownership refusals; anything else is an
1221 // ordinary failure with nothing to escalate.
1222 Err(err) if err.kind() == io::ErrorKind::PermissionDenied => err,
1223 Err(err) => return Err(ProvisionError::io("removing", dest, err)),
1224 };
1225
1226 let unprivileged = |source: io::Error, reason: String| ProvisionError::RemoveUnprivileged {
1227 path: dest.to_path_buf(),
1228 source,
1229 reason,
1230 };
1231
1232 // The map to re-enter: the single map the child writes itself, or the
1233 // delegate and extents a range map is applied through.
1234 let map_source = match identity::compose_child_map(map, mapper) {
1235 Ok(source) => source,
1236 Err(reason) => return Err(unprivileged(denied, reason)),
1237 };
1238
1239 let parent = match dest.parent() {
1240 Some(parent) if !parent.as_os_str().is_empty() => parent,
1241 _ => Path::new("."),
1242 };
1243 let Some(leaf) = dest.file_name() else {
1244 return Err(ProvisionError::DestUnusable {
1245 path: dest.to_path_buf(),
1246 });
1247 };
1248 let nul = |_| {
1249 ProvisionError::io(
1250 "removing",
1251 dest,
1252 io::Error::from(io::ErrorKind::InvalidInput),
1253 )
1254 };
1255 let parent_c = std::ffi::CString::new(parent.as_os_str().as_bytes()).map_err(nul)?;
1256 let leaf_c = std::ffi::CString::new(leaf.as_bytes()).map_err(nul)?;
1257
1258 crate::mechanism::remove_tree_mapped(&parent_c, &leaf_c, map_source.child_map()).map_err(
1259 |failure| match failure {
1260 crate::mechanism::RemoveFailure::Step { step, errno } => {
1261 ProvisionError::io(step.describe(), dest, io::Error::from_raw_os_error(errno))
1262 }
1263 crate::mechanism::RemoveFailure::Map(err) => unprivileged(denied, err.to_string()),
1264 crate::mechanism::RemoveFailure::Malformed => ProvisionError::io(
1265 "reading the removal process's outcome",
1266 dest,
1267 io::Error::from_raw_os_error(Errno::PROTO.raw_os_error()),
1268 ),
1269 },
1270 )
1271}
1272
1273/// A writer that forwards to an inner writer until a byte ceiling is crossed,
1274/// then fails.
1275///
1276/// It bounds what an untrusted producer can make the caller buffer, in the one
1277/// place the bound cannot be ignored. A mirror is free to answer a request for
1278/// a few megabytes with gigabytes whatever its framing claimed, and a
1279/// caller-supplied [`Fetch`] is free to ignore the size a request declares; a
1280/// gzip segment is free to expand a kilobyte into a gigabyte. In every case the
1281/// digest that would catch the substitution runs only after the bytes have been
1282/// spent, so the sink is where the ceiling belongs.
1283#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1284pub(crate) struct LimitedWriter<W> {
1285 inner: W,
1286 remaining: u64,
1287}
1288
1289#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1290impl<W: io::Write> LimitedWriter<W> {
1291 /// Wraps `inner`, failing the write that would carry it past `limit` bytes
1292 /// in total.
1293 ///
1294 /// Generic over the writer rather than fixed to a `&mut dyn Write`, because
1295 /// a batched fetch hands each job a sink of its own to own for the length
1296 /// of the batch. A borrowed writer still fits: `&mut W` is a `Write`.
1297 pub(crate) fn new(inner: W, limit: u64) -> LimitedWriter<W> {
1298 LimitedWriter {
1299 inner,
1300 remaining: limit,
1301 }
1302 }
1303
1304 /// The writer back, once the cap has done its work.
1305 pub(crate) fn into_inner(self) -> W {
1306 self.inner
1307 }
1308}
1309
1310#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1311impl<W: io::Write> io::Write for LimitedWriter<W> {
1312 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1313 if buf.len() as u64 > self.remaining {
1314 return Err(io::Error::new(
1315 io::ErrorKind::InvalidData,
1316 "the stream exceeded the maximum size accepted for it",
1317 ));
1318 }
1319 let written = self.inner.write(buf)?;
1320 self.remaining -= written as u64;
1321 Ok(written)
1322 }
1323
1324 fn flush(&mut self) -> io::Result<()> {
1325 self.inner.flush()
1326 }
1327}
1328
1329/// The current time as a Unix timestamp, or `None` if the clock is before
1330/// the epoch (which would make a freshness check meaningless).
1331///
1332/// Here rather than in any one layer because reading the wall clock is nobody's
1333/// question: a signature's validity window, a release's deadline, and the
1334/// timestamp a published pool stamps are three layers' worth of caller, and a
1335/// second reading of the clock is a second answer to when it is.
1336#[cfg(any(feature = "debian", feature = "gentoo"))]
1337pub(crate) fn now_epoch() -> Option<i64> {
1338 std::time::SystemTime::now()
1339 .duration_since(std::time::UNIX_EPOCH)
1340 .ok()
1341 .map(|since| since.as_secs() as i64)
1342}
1343
1344/// The path of a staging file for `dest`: a hidden name beside it, unique to
1345/// the call that asked for it.
1346///
1347/// The name is unique so that concurrent writers of one destination — provisions
1348/// sharing a package cache, publishers sharing a pool — each stage their own
1349/// file and each publish it with a rename. A staging name shared between them
1350/// would let one writer's rename consume the other's file, failing the second
1351/// with `ENOENT`.
1352///
1353/// The staging file is a sibling of the destination, so the rename that
1354/// publishes it stays within one directory and one filesystem.
1355#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1356fn staging_path(dest: &Path) -> PathBuf {
1357 let dir = dest.parent().unwrap_or(Path::new("."));
1358 let name = dest.file_name().unwrap_or(dest.as_os_str());
1359 let mut staging = OsString::from(".");
1360 staging.push(name);
1361 staging.push(format!(".{}.partial", crate::host::unique_hex()));
1362 dir.join(staging)
1363}
1364
1365/// The directory a run downloads its packages into, and whether the run owns it.
1366///
1367/// A caller that names a cache directory owns it: it survives the run, which is
1368/// the whole point of naming one. A run that names none downloads into a derived
1369/// sibling of the tree it is building, and that directory is the run's own.
1370///
1371/// A derived cache is removed when the value is dropped rather than at the end
1372/// of the successful path, so a failure disposes of it exactly as a success
1373/// does. Every `?` between creating the cache and finishing the bootstrap would
1374/// otherwise leave a base system's worth of packages beside a rootfs that was
1375/// never published, and nothing later clears it: the publication's own cleanup
1376/// removes the staging tree, and [`remove`] removes the destination and its
1377/// lock, neither of which this is.
1378#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1379pub(crate) struct PackageCache {
1380 path: PathBuf,
1381 owned: bool,
1382}
1383
1384#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1385impl PackageCache {
1386 /// The cache for a run building `tree`: the caller's, or a sibling of the
1387 /// tree named after it with `suffix`.
1388 ///
1389 /// A sibling, not a child: an extracted package's entries land under the
1390 /// tree, so a cache beneath it could be overwritten before it is read, but a
1391 /// sibling is out of the extraction's contained reach.
1392 ///
1393 /// The suffix is the layer's, so two provisioners building one destination
1394 /// do not each dispose of the other's downloads.
1395 pub(crate) fn beside(tree: &Path, cache_dir: Option<&Path>, suffix: &str) -> PackageCache {
1396 match cache_dir {
1397 Some(dir) => PackageCache {
1398 path: dir.to_path_buf(),
1399 owned: false,
1400 },
1401 None => {
1402 let mut sibling = tree.as_os_str().to_os_string();
1403 sibling.push(suffix);
1404 PackageCache {
1405 path: PathBuf::from(sibling),
1406 owned: true,
1407 }
1408 }
1409 }
1410 }
1411
1412 /// The directory itself, to download into and to read back from.
1413 pub(crate) fn path(&self) -> &Path {
1414 &self.path
1415 }
1416}
1417
1418#[cfg(any(feature = "debian", feature = "alpine", feature = "gentoo"))]
1419impl Drop for PackageCache {
1420 fn drop(&mut self) {
1421 if self.owned {
1422 let _ = std::fs::remove_dir_all(&self.path);
1423 }
1424 }
1425}
1426
1427/// Writes bytes to `dest` atomically, via a staging file in its directory and a
1428/// rename onto the destination.
1429///
1430/// This is how the Debian layer publishes a cache entry — a fetched index, a
1431/// downloaded package, a pool file — so that two processes provisioning at once
1432/// cannot show each other a half-written file. The Alpine and Gentoo layers
1433/// stage and rename through [`staging_path`] at the point they stream a body to
1434/// disk instead, because what they publish arrives as a stream rather than as
1435/// bytes already in hand.
1436///
1437/// The staging file is created exclusively, which establishes that the name is
1438/// this call's alone and refuses a symlink pre-planted at it rather than writing
1439/// through it. [`staging_path`] describes why the name is unique per call.
1440///
1441/// The staging file is not synced before the rename. What a reader takes from
1442/// these files is verified against a digest, so a file left short by a crash is
1443/// rejected and written again, which is cheaper than syncing every write against
1444/// a failure that costs only a repeat.
1445///
1446/// Errors name the staging file only when the failure is the staging file's
1447/// own; the caller wraps them against the destination it asked for.
1448#[cfg(feature = "debian")]
1449pub(crate) fn write_atomically(dest: &Path, bytes: &[u8]) -> io::Result<()> {
1450 use std::io::Write as _;
1451
1452 let tmp = staging_path(dest);
1453
1454 // Only a failure after this point removes the staging file: the exclusive
1455 // create is what establishes that the name is this call's alone, so a
1456 // cleanup ahead of it could delete a file the caller does not own.
1457 let mut file = fs::OpenOptions::new()
1458 .write(true)
1459 .create_new(true)
1460 .open(&tmp)?;
1461
1462 // Leaving a staging file behind would accumulate in a directory that
1463 // outlives the call, since no later one reuses the name.
1464 if let Err(err) = file.write_all(bytes) {
1465 let _ = fs::remove_file(&tmp);
1466 return Err(err);
1467 }
1468 drop(file);
1469
1470 fs::rename(&tmp, dest).inspect_err(|_| {
1471 let _ = fs::remove_file(&tmp);
1472 })
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477 use super::*;
1478 use crate::scratch::Scratch;
1479
1480 struct Touch;
1481
1482 impl Provisioner for Touch {
1483 fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
1484 let staging = request.staging();
1485 fs::write(staging.join("marker"), b"provisioned")
1486 .map_err(|err| ProvisionError::io("writing", staging, err))
1487 }
1488 }
1489
1490 struct Fail;
1491
1492 impl Provisioner for Fail {
1493 fn provision(&mut self, _request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
1494 Err(ProvisionError::other("deliberate failure"))
1495 }
1496 }
1497 #[test]
1498 fn remove_is_idempotent_on_a_missing_path() {
1499 let dir = Scratch::for_test("provision", "remove-missing");
1500 remove(dir.join("never-created")).expect("removing nothing is not an error");
1501 }
1502
1503 #[test]
1504 fn remove_deletes_a_plain_tree() {
1505 let dir = Scratch::for_test("provision", "remove-plain");
1506 let tree = dir.join("tree");
1507 fs::create_dir_all(tree.join("a/b")).unwrap();
1508 fs::write(tree.join("a/b/file"), b"x").unwrap();
1509 fs::write(tree.join("top"), b"y").unwrap();
1510 std::os::unix::fs::symlink("a", tree.join("link")).unwrap();
1511 remove(&tree).expect("a caller-owned tree removes plainly");
1512 assert!(!tree.exists());
1513 }
1514
1515 #[test]
1516 fn remove_deletes_a_non_directory() {
1517 let dir = Scratch::for_test("provision", "remove-file");
1518 let file = dir.join("file");
1519 fs::write(&file, b"x").unwrap();
1520 remove(&file).expect("a plain file is removed as itself");
1521 assert!(!file.exists());
1522 }
1523
1524 #[test]
1525 fn ensure_creates_then_reuses() {
1526 let dir = Scratch::for_test("provision", "create");
1527 let dest = dir.join("rootfs");
1528 assert_eq!(ensure(&dest, &mut Touch).unwrap(), Provisioned::Created);
1529 assert!(dest.join("marker").is_file());
1530 assert_eq!(ensure(&dest, &mut Fail).unwrap(), Provisioned::Existing);
1531 }
1532
1533 #[test]
1534 fn ensure_failure_leaves_no_dest_and_no_staging() {
1535 let dir = Scratch::for_test("provision", "fail");
1536 let dest = dir.join("rootfs");
1537 let err = ensure(&dest, &mut Fail).unwrap_err();
1538 assert!(matches!(err, ProvisionError::Other { .. }), "{err}");
1539 assert!(!dest.exists());
1540 assert!(!dir.join(".rootfs.staging").exists());
1541 }
1542
1543 #[test]
1544 fn remove_takes_the_publication_lock_with_the_rootfs() {
1545 let dir = Scratch::for_test("provision", "lock-round-trip");
1546 let dest = dir.join("rootfs");
1547 let lock = dir.join("rootfs.lock");
1548 assert_eq!(ensure(&dest, &mut Touch).unwrap(), Provisioned::Created);
1549 assert!(lock.is_file(), "ensure takes a lock beside the destination");
1550 remove(&dest).expect("the published rootfs is removed");
1551 assert!(!dest.exists());
1552 assert!(!lock.exists(), "the lock goes with the rootfs");
1553 }
1554
1555 #[test]
1556 fn declining_the_lock_removes_the_tree_and_leaves_the_sibling() {
1557 // A caller whose `dest` holds published trees rather than being one:
1558 // the `.lock` beside it belongs to whoever wrote it, and was never a
1559 // target of this removal.
1560 let dir = Scratch::for_test("provision", "keep-lock");
1561 let dest = dir.join("work");
1562 let sibling = dir.join("work.lock");
1563 assert_eq!(ensure(&dest, &mut Touch).unwrap(), Provisioned::Created);
1564 assert!(sibling.is_file());
1565 Remove::new(&dest)
1566 .remove_lock(false)
1567 .run()
1568 .expect("the tree is removed");
1569 assert!(!dest.exists(), "the tree itself goes either way");
1570 assert!(
1571 sibling.is_file(),
1572 "the sibling is not this removal's to take"
1573 );
1574 }
1575
1576 #[test]
1577 fn a_removal_takes_the_lock_unless_asked_not_to() {
1578 // The builder defaults to `remove`'s behaviour, so reaching for
1579 // `Remove` to name a map does not silently change what is deleted.
1580 let dir = Scratch::for_test("provision", "builder-lock");
1581 let dest = dir.join("rootfs");
1582 let lock = dir.join("rootfs.lock");
1583 assert_eq!(ensure(&dest, &mut Touch).unwrap(), Provisioned::Created);
1584 Remove::new(&dest).run().expect("the rootfs is removed");
1585 assert!(!lock.exists(), "the default is the round-trip");
1586 }
1587
1588 #[test]
1589 fn remove_clears_the_lock_a_failed_provision_left_behind() {
1590 let dir = Scratch::for_test("provision", "lock-orphan");
1591 let dest = dir.join("rootfs");
1592 let lock = dir.join("rootfs.lock");
1593 ensure(&dest, &mut Fail).unwrap_err();
1594 assert!(!dest.exists());
1595 assert!(lock.is_file(), "the failed run left its lock behind");
1596 // The destination is already absent, so this is the idempotent path;
1597 // it still clears the orphan.
1598 remove(&dest).expect("removing an absent rootfs is not an error");
1599 assert!(!lock.exists());
1600 }
1601
1602 #[test]
1603 fn ensure_rejects_a_non_directory_dest() {
1604 let dir = Scratch::for_test("provision", "nondir");
1605 let dest = dir.join("rootfs");
1606 fs::write(&dest, b"file in the way").unwrap();
1607 let err = ensure(&dest, &mut Touch).unwrap_err();
1608 assert!(matches!(err, ProvisionError::DestUnusable { .. }), "{err}");
1609 }
1610
1611 #[test]
1612 fn ensure_clears_stale_staging() {
1613 let dir = Scratch::for_test("provision", "stale");
1614 let dest = dir.join("rootfs");
1615 let staging = dir.join(".rootfs.staging");
1616 fs::create_dir_all(staging.join("leftover")).unwrap();
1617 assert_eq!(ensure(&dest, &mut Touch).unwrap(), Provisioned::Created);
1618 assert!(dest.join("marker").is_file());
1619 assert!(!dest.join("leftover").exists());
1620 assert!(!staging.exists());
1621 }
1622
1623 #[test]
1624 fn ensure_serializes_concurrent_callers() {
1625 use std::sync::atomic::{AtomicUsize, Ordering};
1626
1627 static RUNS: AtomicUsize = AtomicUsize::new(0);
1628
1629 struct Counting;
1630
1631 impl Provisioner for Counting {
1632 fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
1633 let staging = request.staging();
1634 RUNS.fetch_add(1, Ordering::SeqCst);
1635 // Stay in the provisioner long enough that the other
1636 // threads pile up on the lock.
1637 std::thread::sleep(std::time::Duration::from_millis(50));
1638 fs::write(staging.join("marker"), b"x")
1639 .map_err(|err| ProvisionError::io("writing", staging, err))
1640 }
1641 }
1642
1643 let dir = Scratch::for_test("provision", "concurrent");
1644 let dest = dir.join("rootfs");
1645 let outcomes: Vec<Provisioned> = std::thread::scope(|scope| {
1646 let handles: Vec<_> = (0..4)
1647 .map(|_| scope.spawn(|| ensure(&dest, &mut Counting).unwrap()))
1648 .collect();
1649 handles.into_iter().map(|h| h.join().unwrap()).collect()
1650 });
1651 assert_eq!(RUNS.load(Ordering::SeqCst), 1);
1652 assert_eq!(
1653 outcomes
1654 .iter()
1655 .filter(|o| **o == Provisioned::Created)
1656 .count(),
1657 1
1658 );
1659 }
1660
1661 #[cfg(feature = "debian")]
1662 #[test]
1663 fn concurrent_writes_of_one_cache_entry_all_succeed() {
1664 // The shared-cache race: several provisions download the same package
1665 // at once and publish it under the same content-addressed name. Every
1666 // writer must succeed, and the published bytes must be the package.
1667 let dir = Scratch::for_test("provision", "cache-race");
1668 let bytes = vec![0xab; 64 * 1024];
1669 let writers: Vec<_> = (0..8)
1670 .map(|_| {
1671 let dir = dir.to_path_buf();
1672 let bytes = bytes.clone();
1673 std::thread::spawn(move || write_atomically(&dir.join("entry"), &bytes))
1674 })
1675 .collect();
1676 for writer in writers {
1677 writer.join().unwrap().expect("a concurrent write succeeds");
1678 }
1679 assert_eq!(fs::read(dir.join("entry")).unwrap(), bytes);
1680
1681 // Nothing is left staged: the cache holds the published entry alone.
1682 let leftovers: Vec<_> = fs::read_dir(&dir)
1683 .unwrap()
1684 .map(|entry| entry.unwrap().file_name())
1685 .filter(|name| name != "entry")
1686 .collect();
1687 assert!(leftovers.is_empty(), "staging files remain: {leftovers:?}");
1688 }
1689
1690 #[cfg(feature = "debian")]
1691 #[test]
1692 fn a_failed_publish_leaves_nothing_staged() {
1693 // Staging names are unique per call, so a failure that left one behind
1694 // would accumulate in a cache directory rather than being reused. The
1695 // publishing rename is made to fail by occupying the destination with
1696 // a non-empty directory.
1697 let dir = Scratch::for_test("provision", "cache-publish-failure");
1698 fs::create_dir(dir.join("entry")).unwrap();
1699 fs::write(dir.join("entry").join("occupant"), b"").unwrap();
1700 assert!(write_atomically(&dir.join("entry"), b"payload").is_err());
1701 let leftovers: Vec<_> = fs::read_dir(&dir)
1702 .unwrap()
1703 .map(|entry| entry.unwrap().file_name())
1704 .filter(|name| name != "entry")
1705 .collect();
1706 assert!(leftovers.is_empty(), "staging files remain: {leftovers:?}");
1707 }
1708}