Skip to main content

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}