Skip to main content

ferroday_cage/provision/debian/
mod.rs

1//! The Debian userland provisioner: bootstrap a suite rootfs from the archive.
2//!
3//! [`Debian`] implements [`Provisioner`]: it fetches the
4//! archive's signed release, resolves the base system plus the caller's
5//! packages, downloads and extracts them, and — for a full bootstrap —
6//! configures them by running dpkg inside a [`Cage`](crate::Cage) rooted at
7//! the staging tree. No external tool is invoked; the archive is spoken to
8//! directly over a pluggable [`Fetch`] transport, and its signature is
9//! verified against an embedded keyring.
10//!
11//! The read half is reachable on its own. [`Debian::resolve`] reports the
12//! resolved package set without downloading, for previewing a bootstrap or
13//! keying a build cache on it, and [`Debian::available`] reports the names the
14//! configured archives offer at all — the question a resolve cannot answer,
15//! since it reports a closure rather than a catalogue. Beyond the read half,
16//! [`DebianBuilder::pre_configure_overlay`] lays caller configuration into the
17//! rootfs before its maintainer scripts run, and [`Pool`] writes a local
18//! trusted pool a later bootstrap resolves against.
19//!
20//! No `.deb` is ever held in memory. A download is written to a staging file as
21//! it arrives and digested on the way through, and published with a rename only
22//! once the digest matches; installing reads it back the same way, streaming the
23//! `data.tar` through the decompressor into the extractor; and [`Pool`] reaches a
24//! control member by seeking to it. So a 90 MB kernel package costs what a shell
25//! script costs, whichever direction it is moving in.
26//!
27//! # Example
28//!
29//! ```no_run
30//! use ferroday_cage::provision::{self, debian::Debian};
31//!
32//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
33//! let mut debian = Debian::builder("trixie")
34//!     .include(["build-essential"])
35//!     .build()?;
36//! provision::ensure("/var/lib/machines/trixie", &mut debian)?;
37//! # Ok(())
38//! # }
39//! ```
40
41mod arch;
42mod available;
43mod bootstrap;
44mod deb;
45mod index;
46mod layer;
47mod pin;
48mod plan;
49mod pool;
50mod release;
51mod repository;
52mod resolve;
53mod version;
54
55use layer::InstalledSet;
56use std::fmt;
57use std::io::{self, Read};
58use std::path::{Path, PathBuf};
59
60pub use arch::{Interpreter, foreign_interpreter, host_architecture};
61pub use available::Available;
62pub use index::{ParsePriorityError, Priority, build_depend_names};
63pub use pin::{UnheldPin, UnheldReason};
64pub use plan::{Plan, PlannedPackage, ResolvedArchive};
65pub use pool::Pool;
66pub use repository::{Repository, RepositoryBuilder};
67
68use super::coordinate::{self, Nesting};
69use super::digest::{self, Algorithm};
70use super::document::Carried;
71use super::extract::{Extraction, Placement};
72use super::{
73    BuildLayer, Delegate, Failover, Fetch, FetchError, FetchJob, FetchRequest, HttpFetch,
74    LimitedWriter, PackageCache, ProvisionError, ProvisionEvent, ProvisionRequest, Provisioner,
75    Stream, mirror_url, staging_path, walk_mirrors,
76};
77use crate::IdentityMap;
78use crate::failure::path_io_error;
79use crate::status::ExitStatus;
80use bootstrap::{Installable, StatOverride};
81use index::{Index, Package};
82use pin::Pins;
83
84/// The suffix a run-owned `.deb` cache takes on the tree it is building.
85const DEB_CACHE: &str = ".fcage-debs";
86use crate::provision::openpgp::Keyring;
87use release::Release;
88
89/// The mode a bootstrapped rootfs's own root directory takes.
90///
91/// The mode `base-files` ships `/` as, applied to the tree being assembled
92/// rather than read off whichever package was extracted last; see
93/// [`extract_packages`].
94const ROOTFS_ROOT_MODE: u32 = 0o755;
95
96/// The Debian archive keyring shipped with the crate, the binary
97/// `debian-archive-keyring`. A caller can substitute their own with
98/// [`DebianBuilder::keyring`].
99///
100/// Visible to the crate because the verification apparatus is tested against it:
101/// a real archive keyring exercises the subkey walk and the expiry arithmetic in
102/// ways a synthesized certificate cannot.
103pub(crate) const EMBEDDED_KEYRING: &[u8] = include_bytes!("keyring/debian-archive-keyring.gpg");
104
105/// The default mirror: the Debian content-delivery front end.
106const DEFAULT_MIRROR: &str = "http://deb.debian.org/debian";
107
108/// A Debian userland provisioner.
109///
110/// Built with [`Debian::builder`] and run through
111/// [`ensure`](super::ensure). See the module documentation for the pipeline.
112///
113/// A `Debian` is `Send`, so a caller may build one on a coordinating thread and
114/// provision on a worker. It is not `Sync`: every operation takes `&mut self`,
115/// so a shared reference cannot do anything with one. [`Fetch`] carries the
116/// same bound for this reason.
117pub struct Debian<'a> {
118    suite: String,
119    architecture: String,
120    base_priority: Priority,
121    includes: Vec<String>,
122    excludes: Vec<String>,
123    extract_only: bool,
124    identity_map: IdentityMap,
125    cache_dir: Option<PathBuf>,
126    overlay: Option<PathBuf>,
127    /// The pristine base a layered build stages its increment over, set by
128    /// [`DebianBuilder::base_layer`]. `None` for a full bootstrap; required by
129    /// [`Debian::stage_layer`] and [`Debian::resolve_layer`].
130    base_layer: Option<PathBuf>,
131    /// A plan to install verbatim in place of resolving, set by
132    /// [`DebianBuilder::plan`]. `None` for an ordinary bootstrap, which
133    /// resolves against the archives.
134    plan: Option<Plan>,
135    /// The versions a resolution is held to, set by [`DebianBuilder::pin`].
136    /// Empty for a resolution free to take whatever the archives now offer.
137    pin: Pins,
138    /// The archive sources, the primary at index zero followed by any
139    /// additional repositories, merged into one resolution.
140    repositories: Vec<Repository>,
141    fetcher: Box<dyn Fetch + 'a>,
142}
143
144/// The builder's configuration, borrowed apart from its transport.
145///
146/// Every step of a bootstrap reads some of this and none of it reads all, so
147/// the steps took it as positional arguments and re-threaded it through each
148/// other -- fourteen of them at the widest, with `includes` beside `excludes`
149/// and `cache_dir` beside `overlay`, either pair transposable without a
150/// compiler complaint and producing a wrong bootstrap. Gathered once here, a
151/// step names what it reads and a call site cannot mis-order it.
152///
153/// The transport and the observer are not part of it. Both are used mutably,
154/// both are a sink rather than a setting, and every function below takes them
155/// last and in that order.
156struct Bootstrap<'a> {
157    /// The target architecture.
158    architecture: &'a str,
159    /// The archive sources, the primary at index zero.
160    repositories: &'a [Repository],
161    /// The priority floor the base system is seeded from.
162    base_priority: Priority,
163    /// Names the caller asked for beyond the base system.
164    includes: &'a [String],
165    /// Names the caller asked to be left out.
166    excludes: &'a [String],
167    /// A plan to install verbatim in place of resolving.
168    plan: Option<&'a Plan>,
169    /// The versions a resolution is held to.
170    pin: &'a Pins,
171    /// Whether to stop after unpacking, running no configure wave.
172    extract_only: bool,
173    /// The map the bootstrap's cages run under.
174    identity_map: &'a IdentityMap,
175    /// The caller's package cache, or `None` for one beside the staging tree.
176    cache_dir: Option<&'a Path>,
177    /// A tree laid over the staging root between the unpack and configure
178    /// waves.
179    overlay: Option<&'a Path>,
180}
181
182impl<'a> Debian<'a> {
183    /// The configuration and the transport, borrowed apart.
184    ///
185    /// Two values rather than one because the transport is used mutably while
186    /// the configuration is read, and a single borrow of `self` could not be
187    /// both. The destructure is exhaustive, with no `..`: a field added to the
188    /// builder stops this compiling until someone decides whether a bootstrap
189    /// reads it.
190    fn split(&mut self) -> (Bootstrap<'_>, &mut dyn Fetch) {
191        let Debian {
192            architecture,
193            repositories,
194            base_priority,
195            includes,
196            excludes,
197            plan,
198            pin,
199            extract_only,
200            identity_map,
201            cache_dir,
202            overlay,
203            fetcher,
204            // Read by the entry points rather than by a step: it decides which
205            // of them a call is, and by the time there is a bootstrap to
206            // configure the base has already been read.
207            base_layer: _,
208            // The suite names the release a resolution checks against, which
209            // the repository at index zero already carries.
210            suite: _,
211        } = self;
212        (
213            Bootstrap {
214                architecture,
215                repositories,
216                base_priority: *base_priority,
217                includes,
218                excludes,
219                plan: plan.as_ref(),
220                pin,
221                extract_only: *extract_only,
222                identity_map,
223                cache_dir: cache_dir.as_deref(),
224                overlay: overlay.as_deref(),
225            },
226            fetcher.as_mut(),
227        )
228    }
229}
230
231/// [`coordinate::check`], with its refusal reported as this layer's
232/// configuration failure.
233///
234/// The check itself is shared, because a value that becomes a directory in a URL
235/// is refused for the same reasons whichever archive publishes it. What is not
236/// shared is the error: it reports a reason, and each layer says whose
237/// configuration the reason is about.
238pub(super) fn check_coordinate(
239    what: &str,
240    value: &str,
241    nesting: Nesting,
242) -> Result<(), DebianError> {
243    coordinate::check(what, value, nesting).map_err(|reason| DebianError::Config { reason })
244}
245
246/// Refuses a plan handed to `plan()` that contradicts the rest of the
247/// configuration.
248///
249/// A plan describes a resolution that already happened, so a setting shaping a
250/// resolution is a contradiction rather than a refinement, and picking a silent
251/// precedence between the two would produce a rootfs matching neither.
252fn validate_plan(
253    plan: &Plan,
254    suite: &str,
255    architecture: &str,
256    repositories: usize,
257    base_priority_set: bool,
258    includes: &[String],
259    excludes: &[String],
260) -> Result<(), DebianError> {
261    let contradiction = |reason: String| Err(DebianError::Config { reason });
262    if !includes.is_empty() {
263        return contradiction(
264            "a plan already names every package to install, so include() has nothing to \
265             add; drop one of the two"
266                .to_string(),
267        );
268    }
269    if !excludes.is_empty() {
270        return contradiction(
271            "a plan already names every package to install, so exclude() has nothing to \
272             remove; drop one of the two"
273                .to_string(),
274        );
275    }
276    if base_priority_set {
277        return contradiction(
278            "a plan already names its base system, so base_priority() has no seed to \
279             filter; drop one of the two"
280                .to_string(),
281        );
282    }
283    if plan.suite != suite {
284        return contradiction(format!(
285            "the plan resolves suite {} but the bootstrap is configured for {suite}",
286            plan.suite,
287        ));
288    }
289    if plan.architecture != architecture {
290        return contradiction(format!(
291            "the plan resolves architecture {} but the bootstrap is configured for \
292             {architecture}",
293            plan.architecture,
294        ));
295    }
296    // Each package names the archive it came from as an index, and a verbatim
297    // install fetches through the repository at that index, so a plan resolved
298    // against more repositories than are configured has packages with nowhere
299    // to be fetched from.
300    if plan.archives.len() > repositories {
301        return contradiction(format!(
302            "the plan resolved against {} archives but only {repositories} repositories \
303             are configured, so some of its packages name no mirror",
304            plan.archives.len(),
305        ));
306    }
307    // The archive count bounds a plan whose packages agree with its own archive
308    // list, which is every plan a document produced: reading one refuses a
309    // package naming an archive the document does not carry. `Plan` is public
310    // and `Clone`, though, so a caller can take one from `resolve()` and edit
311    // it, and the index is what a bootstrap slices the repository list with.
312    // It is checked here, where the configuration is frozen, rather than left to
313    // panic mid-download; `Plan::to_document` makes the same check on the way
314    // out.
315    for package in &plan.packages {
316        if package.archive >= repositories {
317            return contradiction(format!(
318                "the plan's package {} names archive {} but only {repositories} repositories \
319                 are configured, so it names no mirror",
320                package.name, package.archive,
321            ));
322        }
323        // A document is held to this as it is read; a `Plan` is public and
324        // `Clone`, so one taken from `resolve()` and edited reaches here having
325        // passed nothing.
326        if let Some(reason) = package.unaddressable() {
327            return contradiction(reason);
328        }
329    }
330    Ok(())
331}
332
333/// Refuses a plan handed to `pin()` that contradicts the rest of the
334/// configuration.
335///
336/// Far less is a contradiction here than for `plan()`. A pin constrains a
337/// resolution rather than replacing one, so everything that shapes what is
338/// selected — the includes, the exclusions, the base seed's priority floor —
339/// composes with it, and the repository list is free to differ from the one the
340/// pin was resolved against. What remains is a pin that cannot mean anything: a
341/// resolution that will not happen, one for a different coordinate, or one whose
342/// entries state a digest nothing an archive publishes could equal.
343fn validate_pin(
344    pin: &Plan,
345    suite: &str,
346    architecture: &str,
347    plan_set: bool,
348) -> Result<(), DebianError> {
349    let contradiction = |reason: String| Err(DebianError::Config { reason });
350    if plan_set {
351        return contradiction(
352            "plan() installs a resolved plan instead of resolving, so there is no resolution \
353             for pin() to hold to versions; drop one of the two"
354                .to_string(),
355        );
356    }
357    if pin.suite != suite {
358        return contradiction(format!(
359            "the pin resolves suite {} but the bootstrap is configured for {suite}",
360            pin.suite,
361        ));
362    }
363    if pin.architecture != architecture {
364        return contradiction(format!(
365            "the pin resolves architecture {} but the bootstrap is configured for \
366             {architecture}",
367            pin.architecture,
368        ));
369    }
370    // A pin holds a resolution to the digests it records, and `Pins::check`
371    // compares them against the index with a plain `==`. A digest spelled any
372    // other way could only ever mismatch, and would be reported as an archive
373    // that had published past the pin rather than as the malformed entry it is.
374    // The whole rule is applied, not the digest half alone: a pin is a plan
375    // document, read by the same reader, and one it would refuse is worth
376    // refusing here too.
377    for package in &pin.packages {
378        if let Some(reason) = package.unaddressable() {
379            return contradiction(reason);
380        }
381    }
382    Ok(())
383}
384
385impl fmt::Debug for Debian<'_> {
386    /// Renders the bootstrap's settings and its repositories. The fetcher is a
387    /// caller's trait object and renders as its presence rather than its
388    /// contents; each repository names its trust anchor without dumping it.
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.debug_struct("Debian")
391            .field("suite", &self.suite)
392            .field("architecture", &self.architecture)
393            .field("base_priority", &self.base_priority)
394            .field("includes", &self.includes)
395            .field("excludes", &self.excludes)
396            .field("plan", &self.plan)
397            .field("pin", &self.pin)
398            .field("extract_only", &self.extract_only)
399            .field("identity_map", &self.identity_map)
400            .field("cache_dir", &self.cache_dir)
401            .field("overlay", &self.overlay)
402            .field("base_layer", &self.base_layer)
403            .field("repositories", &self.repositories)
404            .field("fetcher", &Delegate("dyn Fetch"))
405            .finish()
406    }
407}
408
409impl<'a> Debian<'a> {
410    /// Returns a builder for a bootstrap of `suite` (for example `trixie`).
411    pub fn builder(suite: impl Into<String>) -> DebianBuilder<'a> {
412        DebianBuilder {
413            suite: suite.into(),
414            architecture: None,
415            mirror: None,
416            mirror_fallbacks: Vec::new(),
417            components: Vec::new(),
418            base_priority: None,
419            includes: Vec::new(),
420            excludes: Vec::new(),
421            plan: None,
422            pin: None,
423            extract_only: false,
424            identity_map: IdentityMap::Single,
425            cache_dir: None,
426            overlay: None,
427            base_layer: None,
428            keyring_path: None,
429            trust_unsigned: false,
430            allow_stale: false,
431            repositories: Vec::new(),
432            fetcher: None,
433        }
434    }
435
436    /// Resolves the install plan for the configured suite, downloading and
437    /// unpacking nothing.
438    ///
439    /// This performs the read half of a bootstrap: it fetches and verifies the
440    /// signed release, confirms it identifies as the requested suite, fetches
441    /// and verifies the package index, and resolves the base system plus the
442    /// caller's [`include`](DebianBuilder::include)s into the exact set of
443    /// packages a bootstrap would install. It stops there — no package is
444    /// downloaded, and no staging tree is created — and returns the resolved
445    /// [`Plan`].
446    ///
447    /// The plan is the same closure [`provision`](super::ensure) installs: both
448    /// resolve through one shared path, so a plan taken immediately before a
449    /// bootstrap describes what that bootstrap lays down. Every digest in it is
450    /// archive-verified, chaining back to the release signature, so the plan is
451    /// a sound basis for a content-addressed build cache. Because nothing runs,
452    /// `resolve` needs neither a `qemu-user` binfmt handler nor an establishable
453    /// identity map, and so serves a foreign architecture the host cannot
454    /// execute.
455    ///
456    /// # Errors
457    ///
458    /// Returns a [`DebianError`] for a fetch failure, a signature or freshness
459    /// failure, a release that does not offer the requested suite,
460    /// architecture, or components, or a caller `include` that resolves to no
461    /// package.
462    ///
463    /// A resolution that cannot be satisfied reports every refusal it met as a
464    /// [`DebianEvent::Unsatisfiable`] and then fails once with all of them
465    /// named, so an install list with four mistakes in it is corrected in one
466    /// pass rather than four.
467    pub fn resolve(&mut self) -> Result<Plan, DebianError> {
468        self.observe(&mut Silent).resolve()
469    }
470
471    /// Reports the names the configured archives offer, resolving nothing.
472    ///
473    /// It fetches and verifies each repository's release and index exactly as a
474    /// resolve does, merges them, and returns the [`Available`] name set.
475    /// Nothing is downloaded, nothing is unpacked, and no closure is computed —
476    /// so it needs neither a `qemu-user` binfmt handler nor an establishable
477    /// identity map, and serves a foreign architecture the host cannot execute.
478    ///
479    /// This is a different question from [`resolve`](Self::resolve), and the
480    /// resolvers cannot stand in for it. A resolve *fails* where a name cannot
481    /// be satisfied rather than answering a question about it — and it fails
482    /// about a whole closure, so a name that is perfectly available appears in
483    /// the failure when something else in its dependency tree is not. A resolve
484    /// does name every refusal rather than the first, which makes a failed one
485    /// a usable list of what to correct, but that is a list of what this
486    /// particular closure could not have and not an answer about a set of
487    /// names. Asking here costs one pass over the index for any number of
488    /// names, where a resolve per name re-fetches and re-parses it every time.
489    ///
490    /// The builder's [`include`](DebianBuilder::include),
491    /// [`exclude`](DebianBuilder::exclude), and
492    /// [`base_priority`](DebianBuilder::base_priority) settings do not apply:
493    /// they shape a resolution, and this performs none. The suite, the
494    /// architecture, the repositories, and the fetcher are what it reads.
495    ///
496    /// # Errors
497    ///
498    /// Returns a [`DebianError`] for a fetch failure, a signature or freshness
499    /// failure, or a release that does not offer the requested suite,
500    /// architecture, or components.
501    pub fn available(&mut self) -> Result<Available, DebianError> {
502        self.observe(&mut Silent).available()
503    }
504
505    /// Resolves the increment a layered build would install over its base,
506    /// downloading and unpacking nothing.
507    ///
508    /// The layered counterpart of [`resolve`](Self::resolve): it reads the base
509    /// layer's already-installed set from its dpkg status database, then
510    /// resolves the configured [`include`](DebianBuilder::include)s against the
511    /// merged archive-and-repository index while treating that set as satisfied,
512    /// returning the [`Plan`] of only the packages the base does not already
513    /// carry. Requires a [`base_layer`](DebianBuilder::base_layer); every digest
514    /// in the plan is archive-verified, exactly as [`resolve`](Self::resolve)
515    /// reports.
516    ///
517    /// Like [`resolve`](Self::resolve) it downloads no package and runs nothing,
518    /// so it needs neither a `qemu-user` binfmt handler nor an establishable
519    /// identity map; it reads only the base's status database and the archives'
520    /// release and index. Use it to preview a layer or key a build-root cache on
521    /// the increment without staging it.
522    ///
523    /// # Errors
524    ///
525    /// Returns a [`DebianError`] for the same fetch, signature, and resolution
526    /// failures as [`resolve`](Self::resolve), and [`DebianError::Config`] when
527    /// no base layer is set or the base is not a fully configured bootstrap.
528    pub fn resolve_layer(&mut self) -> Result<Plan, DebianError> {
529        self.observe(&mut Silent).resolve_layer()
530    }
531
532    /// The body of [`resolve_layer`](Self::resolve_layer), reporting to
533    /// `observer`.
534    ///
535    /// One body for the two public entry points -- the unobserved one above and
536    /// [`Observed::resolve_layer`] -- rather than a body on each.
537    fn resolve_layer_reporting(
538        &mut self,
539        observer: &mut dyn DebianObserver,
540    ) -> Result<Plan, DebianError> {
541        let Some(base) = self.base_layer.clone() else {
542            return Err(DebianError::Config {
543                reason: "resolve_layer requires a base layer set with DebianBuilder::base_layer"
544                    .to_string(),
545            });
546        };
547        // Reading the base's set is what refuses a base that is not a
548        // configured bootstrap, so it runs whether or not a plan makes the
549        // resolution below unnecessary.
550        let assume_installed = layer::read_installed_set(&base)?;
551
552        let suite = self.suite.clone();
553        let (config, fetcher) = self.split();
554        // As for a whole root: a plan is the increment, and staging installs it
555        // verbatim.
556        if let Some(plan) = config.plan {
557            let plan = plan.clone();
558            observer.progress(DebianEvent::Resolved { plan: &plan });
559            return Ok(plan);
560        }
561        let architecture = config.architecture.to_string();
562        let (packages, archives) = resolve_delta(&config, &assume_installed, fetcher, observer)?;
563        Ok(Plan::project(&suite, &architecture, &packages, &archives))
564    }
565
566    /// Stages this provisioner's packages as an increment over the base layer,
567    /// installing only the delta into a disposable overlay `upper`.
568    ///
569    /// Requires a [`base_layer`](DebianBuilder::base_layer). It resolves the
570    /// increment [`resolve_layer`](Self::resolve_layer) reports, downloads it,
571    /// extracts it into `upper`, and configures it by running dpkg in a cage
572    /// rooted on an overlay of the pristine base (the read-only lower) and
573    /// `upper` (the writable increment) — so dpkg reads the base's configured
574    /// database and lands the increment's state in `upper`, the base untouched.
575    /// The returned [`BuildLayer`] owns `upper`: root a build cage on
576    /// [`overlay_rootfs(base, layer.path())`](crate::CageBuilder::overlay_rootfs)
577    /// to build against the merged `base + increment` view, then drop the layer
578    /// to discard the increment.
579    ///
580    /// `upper` is created if absent, along with the overlay work directory beside
581    /// it. The identity map, cache directory, additional repositories, and
582    /// pre-configure overlay configured on the builder all apply, exactly as to a
583    /// full bootstrap; the map must match the one the base was built under.
584    ///
585    /// A call that fails leaves nothing behind: the upper and its work directory
586    /// are disposed of exactly as dropping the returned layer would dispose of
587    /// them, since a failure hands the caller no handle to drop. So is the
588    /// package cache, unless the builder named a
589    /// [`cache_dir`](DebianBuilder::cache_dir), which is the caller's and
590    /// survives either way.
591    ///
592    /// # Preconditions
593    ///
594    /// The increment's configure wave runs the target's binaries, so a foreign
595    /// architecture needs the same `qemu-user` binfmt handler a full bootstrap
596    /// does, and a range identity map must be establishable — both checked before
597    /// any download. It roots a cage on an unprivileged overlay, so the host must
598    /// support one on `upper`'s filesystem;
599    /// [`host::overlay_blocker`](crate::host::overlay_blocker) reports what is
600    /// missing, and `stage_layer` refuses a host that cannot before downloading.
601    ///
602    /// # Errors
603    ///
604    /// Returns [`ProvisionError`], with a [`DebianError`] surfaced through
605    /// [`ProvisionError::Other`] for a fetch, resolution, or configuration
606    /// failure, an unmet host precondition, a base that is not a configured
607    /// bootstrap, or no base layer set.
608    pub fn stage_layer(&mut self, upper: impl AsRef<Path>) -> Result<BuildLayer, ProvisionError> {
609        self.observe(&mut Silent).stage_layer(upper)
610    }
611
612    /// The body of [`stage_layer`](Self::stage_layer), reporting to `observer`.
613    fn stage_layer_reporting(
614        &mut self,
615        upper: &Path,
616        observer: &mut dyn DebianObserver,
617    ) -> Result<BuildLayer, ProvisionError> {
618        // A base layer is required; the increment has nothing to layer over
619        // otherwise. Cloned rather than borrowed: the configuration is borrowed
620        // out of the same value below, and the base is one path.
621        let Some(base) = self.base_layer.clone() else {
622            return Err(wrap(DebianError::Config {
623                reason: "stage_layer requires a base layer set with DebianBuilder::base_layer"
624                    .to_string(),
625            }));
626        };
627
628        // Preflights before any download, the same fail-early posture the full
629        // bootstrap takes: the increment's configure wave runs the target's
630        // binaries, and a range map must be establishable.
631        arch::preflight_foreign(&arch::host_architecture(), &self.architecture)
632            .map_err(|reason| wrap(DebianError::Config { reason }))?;
633        if !matches!(self.identity_map, IdentityMap::Single)
634            && let Err(reason) = crate::idmap::resolve_default_chain(&self.identity_map)
635        {
636            return Err(wrap(DebianError::Config {
637                reason: format!("the requested identity map is unavailable: {reason}"),
638            }));
639        }
640
641        // The base's configured set seeds the resolver, and reading it refuses a
642        // base that is not a configured bootstrap.
643        let assume_installed = layer::read_installed_set(&base).map_err(wrap)?;
644
645        // Create the upper and refuse a host that cannot establish an
646        // unprivileged overlay on its filesystem, before any download — the
647        // overlay preflight the primitive would make at cage-build time, hoisted
648        // ahead of the work. The probe runs on the upper's parent, its own
649        // filesystem, so its scratch files land beside the upper.
650        std::fs::create_dir_all(upper)
651            .map_err(|err| ProvisionError::io("creating the overlay upper", upper, err))?;
652        // The handle that owns the upper is taken as soon as the upper exists,
653        // not on the way out, so a failure below disposes of a partly-installed
654        // increment instead of orphaning it: there is no handle in an `Err` for
655        // the caller to drop.
656        let layer = BuildLayer::new(upper, self.identity_map.clone());
657        let scratch = upper
658            .parent()
659            .filter(|parent| !parent.as_os_str().is_empty())
660            .unwrap_or(Path::new("."));
661        if let Some(blocker) = crate::host::overlay_blocker(scratch) {
662            return Err(wrap(DebianError::Config {
663                reason: format!("an overlay-rooted build layer cannot be established: {blocker}"),
664            }));
665        }
666
667        // The archives cache: the caller's shared one, or a sibling of the
668        // upper, which goes with this frame however it ends.
669        let archives = PackageCache::beside(upper, self.cache_dir.as_deref(), DEB_CACHE);
670
671        // Resolve the increment against the base's installed set, download it,
672        // extract it into the upper, and configure it through the overlay.
673        // A staging run installs the increment rather than reporting it, so
674        // the archive state goes to the `Resolved` event and no further.
675        let (config, fetcher) = self.split();
676        let wanted: Vec<Wanted> = match config.plan {
677            // A plan is the resolution here as it is for a whole root: the
678            // release and the index are never fetched, and what it names is
679            // installed over the base. Reported as the resolved manifest so an
680            // observer sees the same event either way.
681            Some(plan) => {
682                observer.progress(DebianEvent::Resolved { plan });
683                plan.packages.iter().map(Wanted::from_planned).collect()
684            }
685            None => {
686                let (packages, _archives) =
687                    resolve_delta(&config, &assume_installed, fetcher, observer).map_err(wrap)?;
688                packages.iter().map(Wanted::from_package).collect()
689            }
690        };
691        // An empty increment — every requested package already in the base, or a
692        // plan that names none — needs no download or configuration; the build
693        // root is the base as it stands. Skipping here also keeps dpkg from
694        // being handed an empty unpack wave, which it refuses.
695        if wanted.is_empty() {
696            return Ok(layer);
697        }
698        let installables = acquire_debs(
699            fetcher,
700            config.repositories,
701            &wanted,
702            archives.path(),
703            observer,
704        )?;
705        let statoverride = extract_packages(
706            upper,
707            config.architecture,
708            &installables,
709            archives.path(),
710            observer,
711        )?;
712
713        let env = maintainer_env();
714        let wave = bootstrap::Configure {
715            archives_host: archives.path(),
716            installables: &installables,
717            statoverride: &statoverride,
718            identity_map: config.identity_map,
719            env: &env,
720        };
721        bootstrap::configure_delta(&base, upper, &wave, config.overlay, observer).map_err(wrap)?;
722
723        // The read-only archives bind left an empty mount point in the upper.
724        let _ = std::fs::remove_dir_all(upper.join(bootstrap::ARCHIVES.trim_start_matches('/')));
725
726        Ok(layer)
727    }
728}
729
730/// Builder for a [`Debian`] provisioner.
731pub struct DebianBuilder<'a> {
732    suite: String,
733    architecture: Option<String>,
734    mirror: Option<String>,
735    mirror_fallbacks: Vec<String>,
736    components: Vec<String>,
737    /// The base seed's priority floor; `None` until the caller sets one, so a
738    /// plan can refuse an explicit floor without refusing the default.
739    base_priority: Option<Priority>,
740    includes: Vec<String>,
741    excludes: Vec<String>,
742    /// A previously resolved plan to install verbatim, in place of resolving.
743    plan: Option<Plan>,
744    /// A previously resolved plan to hold a resolution's versions to, rather
745    /// than to install in place of one.
746    pin: Option<Plan>,
747    extract_only: bool,
748    identity_map: IdentityMap,
749    cache_dir: Option<PathBuf>,
750    overlay: Option<PathBuf>,
751    base_layer: Option<PathBuf>,
752    keyring_path: Option<PathBuf>,
753    trust_unsigned: bool,
754    allow_stale: bool,
755    repositories: Vec<Repository>,
756    fetcher: Option<Box<dyn Fetch + 'a>>,
757}
758
759impl fmt::Debug for DebianBuilder<'_> {
760    /// Renders every setting made so far, including the primary repository's,
761    /// which are held separately until [`build`](Self::build) assembles them.
762    /// The keyring is named by its path rather than read, and the fetcher — a
763    /// caller's trait object — by its presence.
764    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765        f.debug_struct("DebianBuilder")
766            .field("suite", &self.suite)
767            .field("architecture", &self.architecture)
768            .field("mirror", &self.mirror)
769            .field("mirror_fallbacks", &self.mirror_fallbacks)
770            .field("components", &self.components)
771            .field("base_priority", &self.base_priority)
772            .field("includes", &self.includes)
773            .field("excludes", &self.excludes)
774            .field("plan", &self.plan)
775            .field("pin", &self.pin)
776            .field("extract_only", &self.extract_only)
777            .field("identity_map", &self.identity_map)
778            .field("cache_dir", &self.cache_dir)
779            .field("overlay", &self.overlay)
780            .field("base_layer", &self.base_layer)
781            .field("keyring_path", &self.keyring_path)
782            .field("trust_unsigned", &self.trust_unsigned)
783            .field("allow_stale", &self.allow_stale)
784            .field("repositories", &self.repositories)
785            .field(
786                "fetcher",
787                &self.fetcher.as_ref().map(|_| Delegate("dyn Fetch")),
788            )
789            .finish()
790    }
791}
792
793impl<'a> DebianBuilder<'a> {
794    /// Sets the target architecture (a Debian name such as `amd64` or
795    /// `arm64`). The default is the host's architecture.
796    pub fn architecture(mut self, architecture: impl Into<String>) -> Self {
797        self.architecture = Some(architecture.into());
798        self
799    }
800
801    /// Sets the primary mirror URL. The default is the Debian CDN front end.
802    ///
803    /// This, along with [`components`](Self::components),
804    /// [`keyring`](Self::keyring), [`trust_unsigned`](Self::trust_unsigned), and
805    /// [`allow_stale_release`](Self::allow_stale_release), configures the
806    /// primary repository — the one that owns the finished rootfs's
807    /// `sources.list`. Additional sources are added with
808    /// [`repository`](Self::repository).
809    ///
810    /// The default transport speaks `http://` and `file://` only, so an
811    /// `https://` mirror needs a fetcher of the caller's own, set with
812    /// [`fetcher`](Self::fetcher); without one the URL is refused when the solve
813    /// first reaches for it. Carrying a TLS stack is the consumer's decision,
814    /// not the crate's — the archive signature, not the transport, is what
815    /// authenticates a package.
816    pub fn mirror(mut self, mirror: impl Into<String>) -> Self {
817        self.mirror = Some(mirror.into());
818        self
819    }
820
821    /// Adds a backstop URL for the primary mirror, tried in order after it when
822    /// a fetch reports the resource missing, fails at the transport, or is
823    /// answered with an unsuccessful HTTP status.
824    ///
825    /// This expresses a live primary mirror with a `snapshot.debian.org`
826    /// fallback for a version that has rotated off the live pool. The fallback
827    /// is a fetch-time concern only: the finished rootfs's `sources.list` names
828    /// the primary mirror, not the backstop. A repository whose backstop is a
829    /// snapshot normally also sets
830    /// [`allow_stale_release`](Self::allow_stale_release), the snapshot's
831    /// release being expired by design.
832    ///
833    /// A backstop is fetched through the same transport as the primary, so the
834    /// scheme constraint of [`mirror`](Self::mirror) applies to it too.
835    pub fn mirror_fallback(mut self, url: impl Into<String>) -> Self {
836        self.mirror_fallbacks.push(url.into());
837        self
838    }
839
840    /// Adds a [`Repository`] merged into the resolution alongside the primary.
841    ///
842    /// An additional repository is a distinct source — a local trusted `.deb`
843    /// pool, a signed feature repository — contributing packages to one merged
844    /// closure. Resolution is highest-version-wins across every repository, so a
845    /// package a feature repository ships at a higher version supersedes the
846    /// primary's, and a package only one repository ships is pulled from it; an
847    /// exact-version tie resolves to the earlier repository, the primary first.
848    /// Each additional repository writes its own
849    /// `/etc/apt/sources.list.d/<name>.list` into the finished rootfs.
850    pub fn repository(mut self, repository: Repository) -> Self {
851        self.repositories.push(repository);
852        self
853    }
854
855    /// Sets the archive components. The default is `main`.
856    pub fn components<I, S>(mut self, components: I) -> Self
857    where
858        I: IntoIterator<Item = S>,
859        S: Into<String>,
860    {
861        self.components = components.into_iter().map(Into::into).collect();
862        self
863    }
864
865    /// Sets the least essential [`Priority`] band the base system seeds from.
866    ///
867    /// The base system is every essential package plus every package at least
868    /// as essential as this floor. The default, [`Priority::Required`], is
869    /// `debootstrap`'s minbase: essential and required packages only. A less
870    /// essential floor such as [`Priority::Important`] additionally seeds that
871    /// band — `cron`, `logrotate`, and the like — matching the corresponding
872    /// bootstrap variant, so a package the archive marks that priority is
873    /// present without being named in [`include`](Self::include). Floors below
874    /// [`Priority::Standard`] are not meaningful for a base system: they seed
875    /// the bulk of the archive.
876    pub fn base_priority(mut self, priority: Priority) -> Self {
877        self.base_priority = Some(priority);
878        self
879    }
880
881    /// Adds packages to install beyond the base system.
882    pub fn include<I, S>(mut self, packages: I) -> Self
883    where
884        I: IntoIterator<Item = S>,
885        S: Into<String>,
886    {
887        self.includes.extend(packages.into_iter().map(Into::into));
888        self
889    }
890
891    /// Installs a previously resolved [`Plan`] verbatim, resolving nothing.
892    ///
893    /// The bootstrap fetches exactly the packages the plan records, by the
894    /// digests it records, and never touches a release or a package index. That
895    /// is the whole win: the index is around 9 MB for trixie amd64, and a
896    /// reproduce mode that has already resolved once has no use for a second
897    /// resolution — nor for the divergence when the archive publishes between
898    /// the two.
899    ///
900    /// # The trust model changes
901    ///
902    /// Skipping the release and the index means the package digests no longer
903    /// chain to an archive signature at install time. **The plan becomes the
904    /// trust anchor**: it was archive-verified when it was produced, and
905    /// installing from it asserts that whoever kept it kept it intact. Each
906    /// `.deb` is still verified against the digest the plan records, so a
907    /// tampered mirror is caught; what is no longer checked is that the plan
908    /// itself still describes what the archive says.
909    ///
910    /// That trade is exactly what a reproduce mode wants and exactly what an
911    /// ordinary build should not take, so it is opt-in by construction: a
912    /// builder that sets no plan resolves as it always has, chaining every
913    /// digest to a repository signature.
914    ///
915    /// # Refused combinations
916    ///
917    /// A plan describes a resolution that already happened, so anything that
918    /// would shape a resolution contradicts it and is refused at
919    /// [`build`](Self::build) with [`DebianError::Config`] rather than given a
920    /// silent precedence: [`include`](Self::include), [`exclude`](Self::exclude),
921    /// and [`base_priority`](Self::base_priority). A plan whose suite or
922    /// architecture disagrees with the builder's is refused the same way, as is
923    /// one naming more archives than there are repositories to fetch them from.
924    /// Where the builder names no architecture the plan's is adopted, since a
925    /// plan states which architecture it is for.
926    ///
927    /// Everything that shapes *how* rather than *what* still applies:
928    /// [`cache_dir`](Self::cache_dir), [`identity_map`](Self::identity_map),
929    /// [`pre_configure_overlay`](Self::pre_configure_overlay), the repositories,
930    /// and the fetcher. A package is fetched from the repository its
931    /// [`PlannedPackage::archive`] names, so a reproduce run may point the same
932    /// plan at a snapshot mirror.
933    ///
934    /// [`base_layer`](Self::base_layer) applies too, and a plan set alongside
935    /// one is the increment rather than the whole root: it is what
936    /// [`Debian::stage_layer`] installs over the base and what
937    /// [`Debian::resolve_layer`] reports, exactly as a plan without a base layer
938    /// is what a whole bootstrap installs. The plan to keep for that is the one
939    /// [`Debian::resolve_layer`] produced.
940    pub fn plan(mut self, plan: Plan) -> Self {
941        self.plan = Some(plan);
942        self
943    }
944
945    /// Holds a resolution to the versions a previously resolved [`Plan`]
946    /// recorded, resolving everything the plan does not name.
947    ///
948    /// Where [`plan`](Self::plan) replaces a resolution, this constrains one.
949    /// The bootstrap fetches and verifies every release and index as it always
950    /// does and computes the closure over what the archives offer now; each
951    /// package the pin names is selected at the pinned version rather than at
952    /// the highest offered, and its recorded digest must be the one the archive
953    /// records for that version. Everything else — a package added to the
954    /// closure since, a dependency the pinned version pulls in, a repository
955    /// the pin says nothing about — resolves normally.
956    ///
957    /// # When this is the one that fits
958    ///
959    /// A plan covering a build whose inputs are not all archives. Where some
960    /// packages are compiled by the build itself and published to a local
961    /// [`Pool`], [`plan`](Self::plan) requires those compiles to be
962    /// byte-reproducible, since it installs every package by a recorded digest
963    /// and a compile that differs by a timestamp no longer matches. Pinning
964    /// only the archive-sourced packages gives a document that fixes the half a
965    /// mirror controls and leaves the locally built half to resolve at whatever
966    /// the pool now holds.
967    ///
968    /// # The trust model does not change
969    ///
970    /// Unlike [`plan`](Self::plan), nothing here leaves the archive signature
971    /// chain: every digest installed is still read from an index whose own
972    /// digest a verified release records. The pin narrows which version is
973    /// selected; it never becomes the authority for what the bytes are. A pin
974    /// whose digest disagrees with the archive's for the same version is
975    /// refused rather than preferred, which is how one version published twice
976    /// over different bytes is caught.
977    ///
978    /// # Refused combinations
979    ///
980    /// A pin and a [`plan`](Self::plan) together are refused at
981    /// [`build`](Self::build) with [`DebianError::Config`]: a plan resolves
982    /// nothing, so there is no resolution for a pin to constrain. A pin whose
983    /// suite or architecture disagrees with the builder's is refused the same
984    /// way, as is one naming a package twice. [`include`](Self::include),
985    /// [`exclude`](Self::exclude), and [`base_priority`](Self::base_priority)
986    /// are *not* refused — a pin constrains the versions a selection resolves
987    /// to and says nothing about what is selected, which is the whole of what
988    /// separates it from a plan.
989    ///
990    /// # Errors at resolution
991    ///
992    /// A pin the archives cannot supply fails the resolve or the bootstrap with
993    /// [`DebianError::Pin`], naming every package at once: the archives have
994    /// published past the pinned version, dropped the package, or record
995    /// different bytes for it. A snapshot mirror is what holds a pin against an
996    /// archive that keeps moving.
997    ///
998    /// ```no_run
999    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1000    /// use ferroday_cage::provision::debian::{Debian, Plan};
1001    ///
1002    /// // The committed document: the versions the mirror served, and nothing
1003    /// // about the packages this build compiles for itself.
1004    /// let kept = Plan::parse_document(&std::fs::read_to_string("archives.plan")?)?;
1005    ///
1006    /// let mut debian = Debian::builder("trixie")
1007    ///     .repository(ferroday_cage::provision::debian::Repository::builder("trixie")
1008    ///         .mirror("file:///srv/pool")
1009    ///         .trust_unsigned(true)
1010    ///         .build()?)
1011    ///     .include(["build-essential", "kernel-image"])
1012    ///     .pin(kept)
1013    ///     .build()?;
1014    /// # let _ = &mut debian;
1015    /// # Ok(())
1016    /// # }
1017    /// ```
1018    pub fn pin(mut self, pin: Plan) -> Self {
1019        self.pin = Some(pin);
1020        self
1021    }
1022
1023    /// Excludes packages from the resolved install closure.
1024    ///
1025    /// An excluded package is dropped from the base seed and skipped as a
1026    /// dependency alternative, so a group such as `network-manager |
1027    /// isc-dhcp-client` resolves to the other alternative rather than pulling
1028    /// the excluded one. This is apt's `pkgname-` deselection: a way to keep a
1029    /// package a broader selection would otherwise draw in. A hard dependency
1030    /// that only the excluded package can satisfy fails the bootstrap rather
1031    /// than producing a broken closure, as does excluding a package that is
1032    /// also an [`include`](Self::include).
1033    pub fn exclude<I, S>(mut self, packages: I) -> Self
1034    where
1035        I: IntoIterator<Item = S>,
1036        S: Into<String>,
1037    {
1038        self.excludes.extend(packages.into_iter().map(Into::into));
1039        self
1040    }
1041
1042    /// Stops after laying out the packages' files, without configuring them.
1043    ///
1044    /// An extract-only rootfs has no configured dpkg database and has run no
1045    /// maintainer scripts. It is the escape hatch for a foreign architecture
1046    /// with no qemu binfmt handler, where the packages' code cannot run.
1047    pub fn extract_only(mut self, extract_only: bool) -> Self {
1048        self.extract_only = extract_only;
1049        self
1050    }
1051
1052    /// Selects the identity map of the cages the bootstrap's dpkg runs in,
1053    /// and with it the ownership the finished rootfs carries.
1054    ///
1055    /// The right map follows from what the rootfs is for. The default,
1056    /// [`IdentityMap::Single`], serves a build or run environment consumed
1057    /// back inside single-identity cages: ownership Debian would assign to
1058    /// system users is quietly flattened to root — reconciled by the
1059    /// bootstrap's ownership stubs — which such cages cannot observe, and
1060    /// the caller-owned output needs no extra host support to manage. A
1061    /// rootfs that is itself the product — deployed, exported, or run under
1062    /// a range map — wants [`IdentityMap::Subordinate`]: the system ids are
1063    /// then real, every chown a maintainer script performs genuinely
1064    /// happens, and no stub or override is involved. That map requires the
1065    /// `subid` feature and a host that can establish it; a bootstrap that
1066    /// cannot fails before any download, never falling back to the flattened
1067    /// form. Non-root ownership a subordinate-mapped bootstrap writes lands
1068    /// as subordinate ids on the host, and a directory so owned refuses the
1069    /// plain caller's removal; [`remove`](super::remove) deletes the
1070    /// produced rootfs either way.
1071    ///
1072    /// Extraction without configuration ([`extract_only`](Self::extract_only))
1073    /// runs no cage and is unaffected by the map.
1074    pub fn identity_map(mut self, map: IdentityMap) -> Self {
1075        self.identity_map = map;
1076        self
1077    }
1078
1079    /// Sets a directory to cache downloaded packages in, content-addressed
1080    /// and reused across bootstraps. Without one, packages are downloaded to
1081    /// a directory beside the tree being built and discarded when the run ends,
1082    /// whether it succeeded or failed.
1083    ///
1084    /// A cache directory set here is the caller's and is never removed, which is
1085    /// what makes it a cache: it is created if absent, and both it and its
1086    /// contents outlive the run.
1087    ///
1088    /// Concurrent bootstraps may share one cache directory, including ones in
1089    /// separate processes and ones whose package sets overlap. Each entry is
1090    /// staged under a name unique to the writer and published with a rename,
1091    /// so a package two bootstraps download at once resolves to the one file
1092    /// they both name, and is verified against its digest before reuse.
1093    pub fn cache_dir(mut self, dir: impl AsRef<Path>) -> Self {
1094        self.cache_dir = Some(dir.as_ref().to_path_buf());
1095        self
1096    }
1097
1098    /// Lays a directory tree into the rootfs after the packages are unpacked
1099    /// and before their maintainer scripts run.
1100    ///
1101    /// The source is a rootfs-shaped directory: its contents are copied into
1102    /// the staging root — `source/etc/locale.gen` becomes `/etc/locale.gen` —
1103    /// preserving file modes and symlinks, under the same kernel-enforced
1104    /// containment as package extraction, so nothing in the source can be
1105    /// written outside the root. A file replaces the package's own copy of the
1106    /// same path, which is how a shipped conffile is overridden; directories
1107    /// merge. Entries land owned by the calling user, which the identity map
1108    /// presents as root inside.
1109    ///
1110    /// The overlay is placed after every package is unpacked and before the
1111    /// configuration wave runs any maintainer script, so a script observes the
1112    /// injected configuration — a debconf pre-seed, a hardware-probe guard.
1113    /// The base system's own packages are configured earlier, so the overlay
1114    /// governs the configuration of the non-essential packages. It applies only
1115    /// to a full bootstrap; [`extract_only`](Self::extract_only) configures
1116    /// nothing and ignores it.
1117    pub fn pre_configure_overlay(mut self, source: impl AsRef<Path>) -> Self {
1118        self.overlay = Some(source.as_ref().to_path_buf());
1119        self
1120    }
1121
1122    /// Stages this provisioner's packages as an increment over an existing base,
1123    /// rather than as a full bootstrap.
1124    ///
1125    /// `base` is a rootfs already provisioned the ordinary way through
1126    /// [`ensure`](super::ensure) — a full, configured bootstrap, not an
1127    /// [`extract_only`](Self::extract_only) tree. Setting it turns the built
1128    /// [`Debian`] into a layered provisioner: [`Debian::stage_layer`] resolves
1129    /// only the packages the base does not already carry and installs that
1130    /// increment into a disposable overlay upper, and [`Debian::resolve_layer`]
1131    /// previews the same increment. The base's already-installed set is read
1132    /// from its own dpkg status database, so the resolver treats every package
1133    /// the base configured — and the virtuals they provide — as satisfied and
1134    /// closes over only the delta.
1135    ///
1136    /// The base and its layers must share a suite and architecture, and must use
1137    /// the same [`identity_map`](Self::identity_map): a base configured under the
1138    /// single-identity map carries flattened ownership that a range-mapped layer
1139    /// would not agree with, and the reverse. The layer's configure wave runs the
1140    /// increment's maintainer scripts, so a foreign architecture needs the same
1141    /// `qemu-user` binfmt handler a full bootstrap does.
1142    ///
1143    /// A [`Debian`] built with a base layer is used through `stage_layer` or
1144    /// `resolve_layer`; it is not passed to [`ensure`](super::ensure), which
1145    /// performs a full bootstrap and does not consult the base.
1146    pub fn base_layer(mut self, base: impl AsRef<Path>) -> Self {
1147        self.base_layer = Some(base.as_ref().to_path_buf());
1148        self
1149    }
1150
1151    /// Overrides the embedded archive keyring with one read from `path` (a
1152    /// binary OpenPGP keyring).
1153    pub fn keyring(mut self, path: impl AsRef<Path>) -> Self {
1154        self.keyring_path = Some(path.as_ref().to_path_buf());
1155        self
1156    }
1157
1158    /// Trusts the archive without verifying its signature, fetching a plain
1159    /// `Release` instead of `InRelease`.
1160    ///
1161    /// This is apt's `[trusted=yes]`: appropriate for a local or `file://`
1162    /// mirror under the caller's control, never for a remote one. Because it
1163    /// drops the only authenticity check, authenticity rests on the transport
1164    /// alone, so [`build`](Self::build) requires one that supplies some: every
1165    /// mirror must be `file://` or `https://`, and any other scheme —
1166    /// `http://` included — is refused.
1167    pub fn trust_unsigned(mut self, trust: bool) -> Self {
1168        self.trust_unsigned = trust;
1169        self
1170    }
1171
1172    /// Accepts a signed release that is past its `Valid-Until`.
1173    ///
1174    /// By default, a `Valid-Until` the release carries must be parseable and
1175    /// unexpired, and the archive signature must not itself have expired, so a
1176    /// stale but validly-signed release cannot be replayed to deliver
1177    /// superseded packages. (A release that omits `Valid-Until`, as the Debian
1178    /// stable suite does, carries no freshness bound and is accepted either
1179    /// way.) Setting this relaxes the freshness check entirely while still
1180    /// verifying the signature, for the legitimate case of pinning a
1181    /// historical archive state — for example a `snapshot.debian.org` suite,
1182    /// whose `Valid-Until` has deliberately passed. It has no effect on a
1183    /// [`trust_unsigned`](Self::trust_unsigned) mirror, which is never
1184    /// freshness-checked.
1185    pub fn allow_stale_release(mut self, allow: bool) -> Self {
1186        self.allow_stale = allow;
1187        self
1188    }
1189
1190    /// Substitutes the transport. The default speaks plain HTTP and
1191    /// `file://`; a consumer needing HTTPS or a proxy supplies their own.
1192    ///
1193    /// [`Fetch`] requires `Send`, so the boxed transport is `Send` too and the
1194    /// finished [`Debian`] can be moved to another thread.
1195    pub fn fetcher(mut self, fetcher: Box<dyn Fetch + 'a>) -> Self {
1196        self.fetcher = Some(fetcher);
1197        self
1198    }
1199
1200    /// Validates the configuration and freezes it into a [`Debian`].
1201    ///
1202    /// # Errors
1203    ///
1204    /// Returns [`DebianError::Config`] for a configuration that contradicts
1205    /// itself — a plan alongside a setting that would reshape it, a plan and a
1206    /// pin at once, or either resolved for a different suite or architecture —
1207    /// and for a suite, component, or architecture the archive cannot be
1208    /// addressed by: an empty value, one carrying whitespace or a control
1209    /// character, or one that would resolve outside the mirror root through a
1210    /// leading `/`, a `..` segment, or an empty or `.` segment. The suite and the component may otherwise
1211    /// contain slashes, since both may name a subtree; the architecture may not.
1212    ///
1213    /// Returns [`DebianError::Io`] when a keyring named by
1214    /// [`keyring`](Self::keyring) or by an additional repository cannot be read.
1215    pub fn build(self) -> Result<Debian<'a>, DebianError> {
1216        if self.suite.trim().is_empty() {
1217            return Err(DebianError::Config {
1218                reason: "no suite was given".to_string(),
1219            });
1220        }
1221        // A plan states which architecture it is for, so a builder that names
1222        // none adopts it: refusing a plan the caller never contradicted, over a
1223        // default the caller never chose, would be a confusing rejection. A pin
1224        // states one for the same reason and is adopted the same way.
1225        let stated = self.plan.as_ref().or(self.pin.as_ref());
1226        let architecture = match (&self.architecture, stated) {
1227            (Some(architecture), _) => architecture.clone(),
1228            (None, Some(plan)) => plan.architecture.clone(),
1229            (None, None) => arch::host_architecture(),
1230        };
1231        // The architecture is a path segment of every index URL and of the
1232        // merged-usr layout, so it passes the same check the suite and the
1233        // components do at repository construction. It is a single segment: the
1234        // archive spells it `binary-<architecture>`, one directory deep.
1235        check_coordinate(
1236            "the bootstrap's architecture",
1237            &architecture,
1238            Nesting::Single,
1239        )?;
1240        let keyring = match &self.keyring_path {
1241            Some(path) => {
1242                std::fs::read(path).map_err(DebianError::at("reading the keyring", path))?
1243            }
1244            None => EMBEDDED_KEYRING.to_vec(),
1245        };
1246        let components = if self.components.is_empty() {
1247            vec!["main".to_string()]
1248        } else {
1249            self.components
1250        };
1251        // The primary repository: its mirror list is the configured mirror
1252        // followed by any snapshot backstops, and its trust anchor is the
1253        // resolved keyring unless the caller trusts it unsigned. Repository
1254        // construction rejects an unsigned http:// mirror, the same refusal the
1255        // flattened configuration made.
1256        let primary_mirror = self.mirror.unwrap_or_else(|| DEFAULT_MIRROR.to_string());
1257        let mut mirrors = Vec::with_capacity(1 + self.mirror_fallbacks.len());
1258        mirrors.push(primary_mirror);
1259        mirrors.extend(self.mirror_fallbacks);
1260        let primary = Repository::primary(
1261            self.suite.clone(),
1262            mirrors,
1263            components,
1264            keyring,
1265            self.trust_unsigned,
1266            self.allow_stale,
1267        )?;
1268
1269        let mut repositories = Vec::with_capacity(1 + self.repositories.len());
1270        repositories.push(primary);
1271        repositories.extend(self.repositories);
1272        repository::validate_distinct_entries(&repositories)?;
1273
1274        if let Some(plan) = &self.plan {
1275            validate_plan(
1276                plan,
1277                &self.suite,
1278                &architecture,
1279                repositories.len(),
1280                self.base_priority.is_some(),
1281                &self.includes,
1282                &self.excludes,
1283            )?;
1284        }
1285
1286        let pin = match &self.pin {
1287            None => Pins::none(),
1288            Some(pin) => {
1289                validate_pin(pin, &self.suite, &architecture, self.plan.is_some())?;
1290                Pins::of(pin)?
1291            }
1292        };
1293
1294        Ok(Debian {
1295            suite: self.suite,
1296            architecture,
1297            base_priority: self.base_priority.unwrap_or(Priority::Required),
1298            includes: self.includes,
1299            excludes: self.excludes,
1300            plan: self.plan,
1301            pin,
1302            extract_only: self.extract_only,
1303            identity_map: self.identity_map,
1304            cache_dir: self.cache_dir,
1305            overlay: self.overlay,
1306            base_layer: self.base_layer,
1307            repositories,
1308            fetcher: self.fetcher.unwrap_or_else(|| Box::new(HttpFetch::new())),
1309        })
1310    }
1311}
1312
1313impl Provisioner for Debian<'_> {
1314    fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
1315        self.provision_reporting(request.staging(), &mut RunObserver { request })
1316    }
1317}
1318
1319impl<'a> Debian<'a> {
1320    /// Binds a [`DebianObserver`] for one run, returning a provisioner that
1321    /// reports to it.
1322    ///
1323    /// A closure is an observer, so a caller that only wants events writes one
1324    /// and never names the trait; a caller that also wants to *stop* the
1325    /// bootstrap implements [`DebianObserver`] and answers its
1326    /// [`cancelled`](DebianObserver::cancelled).
1327    ///
1328    /// The sink is borrowed for the returned value's lifetime, not the
1329    /// provisioner's, so a `Debian` outlives every observed run and whatever the
1330    /// sink borrows is free again as soon as the run ends:
1331    ///
1332    /// ```no_run
1333    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1334    /// use ferroday_cage::provision::debian::{Debian, DebianEvent};
1335    ///
1336    /// let mut debian = Debian::builder("trixie").build()?;
1337    /// let mut log: Vec<String> = Vec::new();
1338    ///
1339    /// let mut sink = |event: DebianEvent<'_>| {
1340    ///     if let DebianEvent::Fetching { url, .. } = event {
1341    ///         log.push(url.to_string());
1342    ///     }
1343    /// };
1344    /// let plan = debian.observe(&mut sink).resolve()?;
1345    /// drop(sink);
1346    ///
1347    /// // `log` is free here, and `debian` is still usable.
1348    /// println!("fetched {} resources for {} packages", log.len(), plan.packages.len());
1349    /// # Ok(())
1350    /// # }
1351    /// ```
1352    pub fn observe<'o>(&'o mut self, sink: &'o mut dyn DebianObserver) -> Observed<'o, 'a> {
1353        Observed { debian: self, sink }
1354    }
1355
1356    /// The body of [`Provisioner::provision`], reporting to `observer` and
1357    /// stopping when it answers `true` to being cancelled.
1358    fn provision_reporting(
1359        &mut self,
1360        staging: &Path,
1361        observer: &mut dyn DebianObserver,
1362    ) -> Result<(), ProvisionError> {
1363        let (config, fetcher) = self.split();
1364        bootstrap_suite(staging, &config, fetcher, observer)
1365    }
1366}
1367
1368/// A [`Debian`] with a progress sink bound for one run.
1369///
1370/// Returned by [`Debian::observe`]. It implements [`Provisioner`], so it is what
1371/// a caller hands to [`provision::ensure`](crate::provision::ensure) when it
1372/// wants progress; and it mirrors the provisioner's own entry points so a
1373/// resolution or a staged layer can report too.
1374pub struct Observed<'o, 'a> {
1375    debian: &'o mut Debian<'a>,
1376    sink: &'o mut dyn DebianObserver,
1377}
1378
1379impl fmt::Debug for Observed<'_, '_> {
1380    /// Renders the bootstrap being observed. The sink is a caller's closure, so
1381    /// it renders as its presence rather than its contents.
1382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1383        f.debug_struct("Observed")
1384            .field("debian", &self.debian)
1385            .field("sink", &Delegate("dyn DebianObserver"))
1386            .finish()
1387    }
1388}
1389
1390impl Observed<'_, '_> {
1391    /// Resolves the install plan, reporting progress. See [`Debian::resolve`].
1392    pub fn resolve(&mut self) -> Result<Plan, DebianError> {
1393        let suite = self.debian.suite.clone();
1394        let (config, fetcher) = self.debian.split();
1395        // A plan set with `plan()` *is* the resolution: a bootstrap installs it
1396        // verbatim and fetches no index. Resolving afresh here would answer with
1397        // a different closure from the one the same value installs, which is the
1398        // one thing this is documented not to do -- and the plan is recommended
1399        // as a build-cache key, where a key that does not describe what is built
1400        // is worse than none.
1401        if let Some(plan) = config.plan {
1402            let plan = plan.clone();
1403            self.sink.progress(DebianEvent::Resolved { plan: &plan });
1404            return Ok(plan);
1405        }
1406        let architecture = config.architecture.to_string();
1407        let (packages, archives) = resolve_packages(&config, fetcher, self.sink)?;
1408        Ok(Plan::project(&suite, &architecture, &packages, &archives))
1409    }
1410
1411    /// Resolves a layered build's increment, reporting progress. See
1412    /// [`Debian::resolve_layer`].
1413    pub fn resolve_layer(&mut self) -> Result<Plan, DebianError> {
1414        self.debian.resolve_layer_reporting(self.sink)
1415    }
1416
1417    /// Reports the names the archives offer, reporting progress. See
1418    /// [`Debian::available`].
1419    pub fn available(&mut self) -> Result<Available, DebianError> {
1420        // Unpinned whatever the builder holds: this reports what the archives
1421        // offer, and a pin describes a selection from it rather than a bound on
1422        // what may be asked about.
1423        let (config, fetcher) = self.debian.split();
1424        let (index, _archives) = merged_index(
1425            fetcher,
1426            config.repositories,
1427            config.architecture,
1428            &Pins::none(),
1429            self.sink,
1430        )?;
1431        Ok(index.into_available())
1432    }
1433
1434    /// Stages a layered build's increment, reporting progress. See
1435    /// [`Debian::stage_layer`].
1436    pub fn stage_layer(&mut self, upper: impl AsRef<Path>) -> Result<BuildLayer, ProvisionError> {
1437        self.debian.stage_layer_reporting(upper.as_ref(), self.sink)
1438    }
1439}
1440
1441impl Provisioner for Observed<'_, '_> {
1442    /// Reports to the sink bound here rather than to the run's observer: the
1443    /// caller chose the richer, Debian-specific channel by using
1444    /// [`Debian::observe`]. Cancellation still comes from the run, so a
1445    /// [`Provision::observe`](crate::provision::Provision::observe) observer
1446    /// can stop a bootstrap it is not reporting on.
1447    fn provision(&mut self, request: &ProvisionRequest<'_>) -> Result<(), ProvisionError> {
1448        let mut observer = ObservedRun {
1449            sink: self.sink,
1450            request,
1451        };
1452        self.debian
1453            .provision_reporting(request.staging(), &mut observer)
1454    }
1455}
1456
1457/// The whole bootstrap, as a free function over borrowed configuration.
1458fn bootstrap_suite(
1459    staging: &Path,
1460    config: &Bootstrap<'_>,
1461    fetcher: &mut dyn Fetch,
1462    observer: &mut dyn DebianObserver,
1463) -> Result<(), ProvisionError> {
1464    let &Bootstrap {
1465        architecture,
1466        repositories,
1467        plan,
1468        extract_only,
1469        identity_map,
1470        cache_dir,
1471        overlay,
1472        // Read by the resolution this delegates to rather than here.
1473        base_priority: _,
1474        includes: _,
1475        excludes: _,
1476        pin: _,
1477    } = config;
1478    // A full bootstrap must be able to run the target's binaries.
1479    if !extract_only {
1480        arch::preflight_foreign(&arch::host_architecture(), architecture)
1481            .map_err(|reason| wrap(DebianError::Config { reason }))?;
1482
1483        // A range map must be establishable before anything is downloaded:
1484        // an unavailable map is a configuration problem, and the identity
1485        // posture is never quietly downgraded to the single-identity form.
1486        if !matches!(identity_map, IdentityMap::Single)
1487            && let Err(reason) = crate::idmap::resolve_default_chain(identity_map)
1488        {
1489            return Err(wrap(DebianError::Config {
1490                reason: format!("the requested identity map is unavailable: {reason}"),
1491            }));
1492        }
1493    }
1494
1495    let packages = match plan {
1496        // A plan is the resolution: the release and the index are never
1497        // fetched, which is the whole of what this saves. The plan is reported
1498        // as the resolved manifest so an observer sees the same event either
1499        // way, and it is reported as it stands rather than re-projected.
1500        Some(plan) => {
1501            observer.progress(DebianEvent::Resolved { plan });
1502            plan.packages.iter().map(Wanted::from_planned).collect()
1503        }
1504        // A bootstrap installs the plan rather than reporting it, so the archive
1505        // state goes to the `Resolved` event and no further.
1506        None => {
1507            let (packages, _archives) =
1508                resolve_packages(config, fetcher, observer).map_err(wrap)?;
1509            packages
1510                .iter()
1511                .map(Wanted::from_package)
1512                .collect::<Vec<_>>()
1513        }
1514    };
1515
1516    // The cache disposes of itself when this frame ends, on the failure paths
1517    // below as on the success path.
1518    let archives = PackageCache::beside(staging, cache_dir, DEB_CACHE);
1519    let installables = acquire_debs(fetcher, repositories, &packages, archives.path(), observer)?;
1520
1521    let statoverride = stage_extract(
1522        staging,
1523        architecture,
1524        &installables,
1525        archives.path(),
1526        observer,
1527    )?;
1528
1529    if !extract_only {
1530        let env = maintainer_env();
1531        let wave = bootstrap::Configure {
1532            archives_host: archives.path(),
1533            installables: &installables,
1534            statoverride: &statoverride,
1535            identity_map,
1536            env: &env,
1537        };
1538        bootstrap::configure(staging, &wave, repositories, overlay, observer).map_err(wrap)?;
1539        // The read-only bind mount left an empty mount point in the rootfs.
1540        let _ = std::fs::remove_dir_all(staging.join(bootstrap::ARCHIVES.trim_start_matches('/')));
1541    }
1542
1543    Ok(())
1544}
1545
1546/// Fetches and verifies every repository's release and index, merges the
1547/// indexes, and resolves the install closure, downloading nothing.
1548///
1549/// Shared by [`Debian::resolve`] and the full bootstrap, so the plan a caller
1550/// previews and the plan a bootstrap installs come from one code path and
1551/// cannot drift apart. Each repository's release is verified against that
1552/// repository's own keyring and bound to its own suite; freshness is enforced
1553/// per repository unless it trusts unsigned or pins a historical snapshot. The
1554/// per-component indexes are tagged with their repository's index and merged
1555/// highest-version-wins across the union, so the resolved set draws each
1556/// package from the repository that ships its winning version.
1557fn resolve_packages(
1558    config: &Bootstrap<'_>,
1559    fetcher: &mut dyn Fetch,
1560    observer: &mut dyn DebianObserver,
1561) -> Result<(Vec<Package>, Vec<ResolvedArchive>), DebianError> {
1562    let (index, archives) = merged_index(
1563        fetcher,
1564        config.repositories,
1565        config.architecture,
1566        config.pin,
1567        observer,
1568    )?;
1569
1570    observer.progress(DebianEvent::Resolving);
1571    let mut base = index.base_seed(config.base_priority);
1572    base.push("apt".to_string());
1573    let (packages, refusals) = resolve::resolve(&index, &base, config.includes, config.excludes);
1574    report_refusals(refusals, observer)?;
1575
1576    let packages = emit_resolved(
1577        config.repositories,
1578        config.architecture,
1579        packages,
1580        &archives,
1581        observer,
1582    );
1583    Ok((packages, archives))
1584}
1585
1586/// Reports every refusal a resolution collected, then fails with all of them
1587/// named. A resolution that refused nothing returns `Ok` and reports nothing.
1588///
1589/// The two halves are both the point. The events let a caller watching progress
1590/// see each refusal as its own item, which is what a list a user has to correct
1591/// wants to be; the error carries the same set as one sentence, for the caller
1592/// who only ever sees the failure. Neither is a summary of the other.
1593fn report_refusals(
1594    refusals: resolve::Refusals,
1595    observer: &mut dyn DebianObserver,
1596) -> Result<(), DebianError> {
1597    if refusals.is_empty() {
1598        return Ok(());
1599    }
1600    for refusal in refusals.iter() {
1601        observer.progress(DebianEvent::Unsatisfiable {
1602            requirement: refusal.requirement(),
1603            required_by: refusal.required_by(),
1604            reason: &refusal.reason(),
1605        });
1606    }
1607    Err(DebianError::Resolve {
1608        reason: refusals.describe(),
1609    })
1610}
1611
1612/// Resolves the increment a layered build installs over its base.
1613///
1614/// The caller's packages are closed against the merged index while
1615/// `assume_installed` — the base layer's configured set, and the virtuals it
1616/// provides — is treated as satisfied, so only the delta the base does not
1617/// already carry is selected. There is no base-system seed and no `apt`: the
1618/// base supplies both. This is [`resolve_packages`]'s read-merge-and-report
1619/// structure with the priority seed replaced by the base's installed set.
1620fn resolve_delta(
1621    config: &Bootstrap<'_>,
1622    assume_installed: &InstalledSet,
1623    fetcher: &mut dyn Fetch,
1624    observer: &mut dyn DebianObserver,
1625) -> Result<(Vec<Package>, Vec<ResolvedArchive>), DebianError> {
1626    let (index, archives) = merged_index(
1627        fetcher,
1628        config.repositories,
1629        config.architecture,
1630        config.pin,
1631        observer,
1632    )?;
1633
1634    observer.progress(DebianEvent::Resolving);
1635    let (packages, refusals) = resolve::resolve_seeded(
1636        &index,
1637        &[],
1638        config.includes,
1639        config.excludes,
1640        assume_installed,
1641    );
1642    report_refusals(refusals, observer)?;
1643
1644    let packages = emit_resolved(
1645        config.repositories,
1646        config.architecture,
1647        packages,
1648        &archives,
1649        observer,
1650    );
1651    Ok((packages, archives))
1652}
1653
1654/// Fetches and verifies every repository's release and index and merges them
1655/// into one package set, downloading no package.
1656///
1657/// The fetch-and-merge half of a resolution, shared by the full-bootstrap
1658/// [`resolve_packages`] and the layered-build [`resolve_delta`] so both draw
1659/// from an identically built and verified index. Each repository's release is
1660/// verified against its own keyring and bound to its own suite; the per-component
1661/// indexes are tagged with their repository's index and merged
1662/// highest-version-wins across the union.
1663fn merged_index(
1664    fetcher: &mut dyn Fetch,
1665    repositories: &[Repository],
1666    architecture: &str,
1667    pins: &Pins,
1668    observer: &mut dyn DebianObserver,
1669) -> Result<(Index, Vec<ResolvedArchive>), DebianError> {
1670    // Each repository contributes one text block per component, tagged with the
1671    // repository's index so the merged index knows where each package came from.
1672    let mut index_inputs: Vec<(usize, String)> = Vec::new();
1673    // The archive state, in repository order, so a package's origin tag indexes
1674    // straight into it.
1675    let mut archives: Vec<ResolvedArchive> = Vec::new();
1676    for (origin, repository) in repositories.iter().enumerate() {
1677        let (release, served) = fetch_release(fetcher, repository, observer)?;
1678        release
1679            .check(
1680                &repository.suite,
1681                architecture,
1682                &repository.components,
1683                repository.require_fresh(),
1684            )
1685            .map_err(DebianError::from)?;
1686        archives.push(ResolvedArchive {
1687            mirror: served.mirror,
1688            suite: repository.suite.clone(),
1689            components: repository.components.clone(),
1690            release_sha256: release.sha256().to_string(),
1691            date: release.date().map(str::to_string),
1692            valid_until: release.valid_until().map(str::to_string),
1693            signed_by: served.signed_by,
1694            signing_key: served.signing_key,
1695            // A record built from a live resolution has no document behind it,
1696            // so there is nothing to carry.
1697            carried: Carried::new(),
1698        });
1699        let text = fetch_index(fetcher, repository, architecture, &release, observer)?;
1700        index_inputs.push((origin, text));
1701    }
1702
1703    let index = Index::merge(
1704        index_inputs
1705            .iter()
1706            .map(|(origin, text)| (*origin, text.as_str())),
1707        architecture,
1708        pins,
1709    );
1710    // Before the closure is computed, so a pin the archives have moved past
1711    // refuses the resolution rather than being quietly resolved around.
1712    pins.check(&index)?;
1713    Ok((index, archives))
1714}
1715
1716/// Projects a resolved package set into a [`Plan`] and reports it through the
1717/// [`Resolved`](DebianEvent::Resolved) event, returning the packages unchanged.
1718///
1719/// The manifest is thus observable inline, without a separate resolve pass, for
1720/// both a full bootstrap and a layered build. The suite named is the primary
1721/// repository's, the one the caller requested; it always exists, as the builder
1722/// seeds element zero with the primary.
1723fn emit_resolved(
1724    repositories: &[Repository],
1725    architecture: &str,
1726    packages: Vec<Package>,
1727    archives: &[ResolvedArchive],
1728    observer: &mut dyn DebianObserver,
1729) -> Vec<Package> {
1730    let suite = repositories
1731        .first()
1732        .map_or("", |repository| repository.suite.as_str());
1733    let plan = Plan::project(suite, architecture, &packages, archives);
1734    observer.progress(DebianEvent::Resolved { plan: &plan });
1735    packages
1736}
1737
1738/// Fetches and verifies a repository's release, returning its parsed form.
1739///
1740/// The fetch walks the repository's mirror list in order, advancing past a URL
1741/// that reports the release missing or fails at the transport. On the signed
1742/// path the archive signature is verified against the repository's keyring;
1743/// `allow_stale` relaxes the check that the signature itself is unexpired, for a
1744/// deliberately pinned historical snapshot.
1745fn fetch_release(
1746    fetcher: &mut dyn Fetch,
1747    repository: &Repository,
1748    observer: &mut dyn DebianObserver,
1749) -> Result<(Release, Served), DebianError> {
1750    let suite = &repository.suite;
1751    let (body, served) = match repository.keyring() {
1752        None => {
1753            let suffix = format!("dists/{suite}/Release");
1754            let (bytes, mirror) =
1755                fetch_from_mirrors(fetcher, &repository.mirrors, &suffix, observer)?;
1756            let body = String::from_utf8(bytes).map_err(|_| {
1757                DebianError::from(release::ReleaseError::Release(
1758                    "Release is not UTF-8".to_string(),
1759                ))
1760            })?;
1761            // Nothing verified it, so no key is named. An empty list is the
1762            // record of that, and is not the same as an omitted repository.
1763            (
1764                body,
1765                Served {
1766                    mirror,
1767                    signed_by: Vec::new(),
1768                    signing_key: Vec::new(),
1769                },
1770            )
1771        }
1772        Some(keyring_bytes) => {
1773            let suffix = format!("dists/{suite}/InRelease");
1774            let (bytes, mirror) =
1775                fetch_from_mirrors(fetcher, &repository.mirrors, &suffix, observer)?;
1776            let keyring = Keyring::parse(keyring_bytes).map_err(release::ReleaseError::from)?;
1777            let verified = keyring
1778                .verify(&bytes, !repository.allow_stale)
1779                .map_err(release::ReleaseError::from)?;
1780            (
1781                verified.body,
1782                Served {
1783                    mirror,
1784                    signed_by: vec![verified.certificate],
1785                    signing_key: vec![verified.signing_key],
1786                },
1787            )
1788        }
1789    };
1790    let release = Release::parse(&body).map_err(DebianError::from)?;
1791    Ok((release, served))
1792}
1793
1794/// How a repository's release was obtained: which mirror answered, and which
1795/// key verified it.
1796///
1797/// The two facts a [`ResolvedArchive`] needs that the release body does not
1798/// itself carry.
1799struct Served {
1800    /// The mirror that served the release.
1801    ///
1802    /// Where the release came from, and not where the packages go to: a
1803    /// bootstrap walks the mirrors its repositories are configured with, so this
1804    /// records the choice that was made rather than deciding a later one. It
1805    /// reaches a caller as [`ResolvedArchive::mirror`].
1806    mirror: String,
1807    /// The verifying certificate's primary-key fingerprint, or empty for a
1808    /// trusted-unsigned repository.
1809    signed_by: Vec<String>,
1810    /// The fingerprint of the component key within that certificate which made
1811    /// the signature, or empty for a trusted-unsigned repository.
1812    signing_key: Vec<String>,
1813}
1814
1815/// Fetches, verifies, and decompresses a repository's package index for every
1816/// component, returning the concatenated text ready to merge.
1817///
1818/// Components are joined with a blank line between them, because that is what
1819/// separates one deb822 paragraph from the next. An index ends with the final
1820/// field of its last stanza rather than with a blank line — the archive's own
1821/// files and this crate's [`Pool`] alike — so joining them end to end would fold
1822/// the first stanza of each component into the last stanza of the one before it,
1823/// and every package that stanza named would be lost from the index without any
1824/// digest check noticing: each component verified against the release
1825/// individually, and only their concatenation was wrong.
1826///
1827/// A component whose index holds no stanzas contributes nothing and is skipped,
1828/// so an empty component — a pool created before the build that fills it — does
1829/// not introduce a stray paragraph break.
1830fn fetch_index(
1831    fetcher: &mut dyn Fetch,
1832    repository: &Repository,
1833    architecture: &str,
1834    release: &Release,
1835    observer: &mut dyn DebianObserver,
1836) -> Result<String, DebianError> {
1837    let mut merged = String::new();
1838    for component in &repository.components {
1839        let text = fetch_component_index(
1840            fetcher,
1841            repository,
1842            architecture,
1843            component,
1844            release,
1845            observer,
1846        )?;
1847        append_component(&mut merged, &text);
1848    }
1849    Ok(merged)
1850}
1851
1852/// Appends one component's index to the merged text, separated from what is
1853/// already there by the blank line that ends a deb822 paragraph.
1854///
1855/// Each index is normalized to end with exactly one newline before the next is
1856/// joined on, so the result is well-formed whether a component's file ends with
1857/// its last field, with a blank line, or with several. An index holding no
1858/// stanzas is skipped rather than contributing an empty paragraph.
1859fn append_component(merged: &mut String, text: &str) {
1860    if text.trim().is_empty() {
1861        return;
1862    }
1863    if !merged.is_empty() {
1864        merged.push('\n');
1865    }
1866    merged.push_str(text.trim_end_matches('\n'));
1867    merged.push('\n');
1868}
1869
1870/// Fetches one component's index, walking the repository's mirror list.
1871///
1872/// The candidate compressions the release lists a digest for are determined
1873/// once, from the release; the mirror walk then tries them in order on each
1874/// mirror, advancing to the next mirror when one reports the index missing. A
1875/// fetched index is verified against the release digest — a mismatch is fatal,
1876/// not a reason to try another mirror — and decompressed.
1877fn fetch_component_index(
1878    fetcher: &mut dyn Fetch,
1879    repository: &Repository,
1880    architecture: &str,
1881    component: &str,
1882    release: &Release,
1883    observer: &mut dyn DebianObserver,
1884) -> Result<String, DebianError> {
1885    // Prefer the smallest available index; every candidate is verified against
1886    // the release digest, so the choice is trust-neutral. Which compressions
1887    // exist is a property of the release, the same across every mirror. The
1888    // release states each index's length beside its digest, and that length
1889    // travels with the fetch as its ceiling: it is signed, it is free, and it
1890    // bounds what a mirror can spend before the digest check runs.
1891    let candidates: Vec<(String, u64, &str)> = ["Packages.xz", "Packages.gz", "Packages"]
1892        .iter()
1893        .filter_map(|suffix| {
1894            let rel = format!("{component}/binary-{architecture}/{suffix}");
1895            release
1896                .digest(&rel)
1897                .map(|(size, digest)| (rel, size, digest))
1898        })
1899        .collect();
1900    if candidates.is_empty() {
1901        return Err(DebianError::Index {
1902            reason: format!("the release lists no index for component {component}"),
1903        });
1904    }
1905
1906    let suite = &repository.suite;
1907    // Two walks, one inside the other: the encodings within one mirror, and the
1908    // mirrors. A mirror that serves none of the encodings has failed over as
1909    // surely as one that answered nothing, so the inner walk's last failure is
1910    // what advances the outer one -- and the inner `exhausted` is unreachable,
1911    // the empty candidate list having been refused above.
1912    walk_mirrors(
1913        &repository.mirrors,
1914        |mirror| {
1915            walk_mirrors(
1916                &candidates,
1917                |(rel, size, digest)| {
1918                    let url = if release.acquire_by_hash() {
1919                        mirror_url(
1920                            mirror,
1921                            &format!(
1922                                "dists/{suite}/{component}/binary-{architecture}/by-hash/SHA256/\
1923                                 {digest}"
1924                            ),
1925                        )
1926                    } else {
1927                        mirror_url(mirror, &format!("dists/{suite}/{rel}"))
1928                    };
1929                    observer.progress(DebianEvent::Fetching { url: &url });
1930                    let bytes = fetch_bytes(fetcher, &url, Some(*size))?;
1931                    verify_digest(rel, &bytes, digest)?;
1932                    decompress_index(rel, &bytes)
1933                },
1934                || DebianError::Index {
1935                    reason: format!("the release lists no index for component {component}"),
1936                },
1937            )
1938        },
1939        || DebianError::Index {
1940            reason: format!("no mirror served an index for component {component}"),
1941        },
1942    )
1943}
1944
1945/// Fetches `suffix` (a mirror-relative path) from the first mirror that serves
1946/// it, advancing past a mirror that reports the resource missing or fails at
1947/// the transport, and returning the last such error when every mirror is
1948/// exhausted.
1949///
1950/// No size travels with these: this fetches a release file, which is the trust
1951/// root and so has nothing signed to declare its length. The fetcher's own
1952/// ceiling is the bound there, which is the right one — a release is small, and
1953/// the alternative would be a ceiling of this module's invention.
1954fn fetch_from_mirrors(
1955    fetcher: &mut dyn Fetch,
1956    mirrors: &[String],
1957    suffix: &str,
1958    observer: &mut dyn DebianObserver,
1959) -> Result<(Vec<u8>, String), DebianError> {
1960    walk_mirrors(
1961        mirrors,
1962        |mirror| {
1963            let url = mirror_url(mirror, suffix);
1964            observer.progress(DebianEvent::Fetching { url: &url });
1965            // The mirror that answered, not the whole list: a repository with a
1966            // snapshot backstop resolves against whichever one served, and a
1967            // record naming the list would describe a choice rather than the
1968            // choice made.
1969            fetch_bytes(fetcher, &url, None).map(|bytes| (bytes, mirror.clone()))
1970        },
1971        || {
1972            DebianError::Fetch(FetchError::NotFound {
1973                url: suffix.to_string(),
1974            })
1975        },
1976    )
1977}
1978
1979impl Failover for DebianError {
1980    /// Reaches through the wrapper this layer carries a transport failure in.
1981    /// A failure that is not a `Fetch` one at all — a digest or signature
1982    /// mismatch over bytes that did arrive — never advances a walk, and falls
1983    /// out of the same match.
1984    fn is_failover(&self) -> bool {
1985        matches!(self, DebianError::Fetch(fetch) if fetch.is_failover())
1986    }
1987}
1988
1989/// Downloads (or reuses from cache) and verifies every selected package.
1990///
1991/// Each package's bytes are fetched from its own repository's mirrors — the
1992/// repository the merged index retained its winning version from — walking that
1993/// mirror list on a missing or failed URL, so a version that has rotated off a
1994/// live pool is served by the repository's snapshot backstop.
1995///
1996/// What is *missing* from the cache is fetched in batches first, through
1997/// [`Fetch::fetch_all`], so a transport that can overlap several requests does.
1998/// That step is an optimization and nothing more: a package it does not deliver
1999/// is downloaded by the walk below exactly as though the batch had not run, and
2000/// the walk is still what decides that a package arrived, verified.
2001fn acquire_debs(
2002    fetcher: &mut dyn Fetch,
2003    repositories: &[Repository],
2004    packages: &[Wanted],
2005    archives_host: &Path,
2006    observer: &mut dyn DebianObserver,
2007) -> Result<Vec<Installable>, ProvisionError> {
2008    std::fs::create_dir_all(archives_host).map_err(|source| {
2009        wrap(DebianError::io(
2010            "creating the package directory",
2011            archives_host,
2012            source,
2013        ))
2014    })?;
2015
2016    // The cache pass and the announcement, before anything is fetched: what a
2017    // caller is told it is downloading is what a batch and the walk between
2018    // them go on to fetch, in the order the plan names them.
2019    let mut missing = Vec::new();
2020    for (position, package) in packages.iter().enumerate() {
2021        if observer.cancelled() {
2022            return Err(ProvisionError::Cancelled);
2023        }
2024        let dest = archives_host.join(format!("{}.deb", package.sha256));
2025        // Whether the cache entry is intact, decided by streaming its digest:
2026        // validating a cached kernel package costs the memory of validating a
2027        // shell script, which is the property every read and write of a `.deb`
2028        // in this crate keeps. A corrupt or truncated entry is simply
2029        // re-downloaded.
2030        let cached = std::fs::File::open(&dest)
2031            .and_then(|mut file| digest::stream(Algorithm::Sha256, &mut file))
2032            .is_ok_and(|(digest, _)| digest == package.sha256);
2033        if !cached {
2034            observer.progress(DebianEvent::Downloading {
2035                package: &package.name,
2036                index: position + 1,
2037                total: packages.len(),
2038            });
2039            missing.push((package, dest));
2040        }
2041    }
2042
2043    prefetch_debs(fetcher, repositories, &missing, observer)?;
2044
2045    for (package, dest) in &missing {
2046        // A package boundary: whatever has been downloaded is complete, and
2047        // the staging tree is untouched, so stopping here costs nothing.
2048        if observer.cancelled() {
2049            return Err(ProvisionError::Cancelled);
2050        }
2051        if dest.is_file() {
2052            continue;
2053        }
2054        let mirrors = &repositories[package.origin].mirrors;
2055        download_deb(fetcher, mirrors, package, dest, observer).map_err(wrap)?;
2056    }
2057
2058    Ok(packages
2059        .iter()
2060        .map(|package| Installable {
2061            name: package.name.clone(),
2062            deb_path: format!("{}/{}.deb", bootstrap::ARCHIVES, package.sha256),
2063            sha256: package.sha256.clone(),
2064        })
2065        .collect())
2066}
2067
2068/// How many packages one batch asks for.
2069///
2070/// Each job holds an open staging file for as long as the batch runs, so this
2071/// is a bound on descriptors rather than on the transport, which decides for
2072/// itself how many of a batch to have in flight. It is also the granularity a
2073/// cancelled bootstrap stops at, which is the reason it is not larger.
2074const BATCH: usize = 32;
2075
2076/// Fills the cache with the packages a bootstrap is about to install, several
2077/// at a time.
2078///
2079/// Every job asks the package's own repository's first mirror, which is the one
2080/// the walk would ask first too. A job that does not arrive, or arrives and does
2081/// not verify, simply leaves the cache without that package, and the walk that
2082/// follows downloads it with every mirror available to it. Nothing here decides
2083/// that a package is present: the rename is, and it happens only over bytes
2084/// whose digest matched.
2085fn prefetch_debs(
2086    fetcher: &mut dyn Fetch,
2087    repositories: &[Repository],
2088    missing: &[(&Wanted, PathBuf)],
2089    observer: &mut dyn DebianObserver,
2090) -> Result<(), ProvisionError> {
2091    for batch in missing.chunks(BATCH) {
2092        if observer.cancelled() {
2093            return Err(ProvisionError::Cancelled);
2094        }
2095        let mut staged = Vec::with_capacity(batch.len());
2096        for (package, dest) in batch {
2097            let Some(mirror) = repositories[package.origin].mirrors.first() else {
2098                continue;
2099            };
2100            let url = mirror_url(mirror, &package.filename);
2101            observer.progress(DebianEvent::Fetching { url: &url });
2102            let path = staging_path(dest);
2103            // Created exclusively, which establishes that the name is this
2104            // call's alone and refuses a symlink pre-planted at it rather than
2105            // writing through it.
2106            let Ok(file) = std::fs::OpenOptions::new()
2107                .write(true)
2108                .create_new(true)
2109                .open(&path)
2110            else {
2111                continue;
2112            };
2113            // Capped as the per-package download is, and for the same reason:
2114            // the size travels with the request, but a transport is free to
2115            // ignore it, and the sink is the one place the bound cannot be.
2116            let sink = digest::DigestWriter::new(Algorithm::Sha256, file);
2117            let cap = package.size.unwrap_or(u64::MAX);
2118            staged.push((*package, dest, url, path, LimitedWriter::new(sink, cap)));
2119        }
2120
2121        let outcomes = {
2122            let mut jobs: Vec<FetchJob<'_>> = staged
2123                .iter_mut()
2124                .map(|(package, _dest, url, _path, sink)| {
2125                    let request = FetchRequest::new(url);
2126                    let request = match package.size {
2127                        Some(size) => request.sized(size),
2128                        None => request,
2129                    };
2130                    FetchJob::new(request, sink)
2131                })
2132                .collect();
2133            fetcher.fetch_all(&mut jobs)
2134        };
2135
2136        for ((package, dest, _url, path, sink), outcome) in staged.into_iter().zip(outcomes) {
2137            let published = outcome.is_ok()
2138                && sink
2139                    .into_inner()
2140                    .finish()
2141                    .is_ok_and(|(actual, _size)| actual == package.sha256)
2142                && std::fs::rename(&path, dest).is_ok();
2143            if !published {
2144                let _ = std::fs::remove_file(&path);
2145            }
2146        }
2147    }
2148    Ok(())
2149}
2150
2151/// Downloads one package into the cache, walking its repository's mirrors.
2152///
2153/// The walk is [`fetch_from_mirrors`]'s: a mirror that reports the package
2154/// missing or fails at the transport advances to the next, and the last such
2155/// failure is reported once none is left. A digest mismatch is not one of those
2156/// — it says the bytes are wrong rather than absent — so it fails the download
2157/// where it happens, as it does everywhere else in the module.
2158fn download_deb(
2159    fetcher: &mut dyn Fetch,
2160    mirrors: &[String],
2161    package: &Wanted,
2162    dest: &Path,
2163    observer: &mut dyn DebianObserver,
2164) -> Result<(), DebianError> {
2165    walk_mirrors(
2166        mirrors,
2167        |mirror| {
2168            let url = mirror_url(mirror, &package.filename);
2169            observer.progress(DebianEvent::Fetching { url: &url });
2170            fetch_verified(fetcher, &url, &package.sha256, package.size, dest)
2171        },
2172        || {
2173            DebianError::Fetch(FetchError::NotFound {
2174                url: package.filename.clone(),
2175            })
2176        },
2177    )
2178}
2179
2180/// Fetches `url` into `dest`, verified and atomically.
2181///
2182/// The body is written to a staging file beside the destination as it arrives,
2183/// digested on the way through, and renamed onto the destination only once the
2184/// digest is `expected`. Two things fall out of doing it in that order. A `.deb`
2185/// never exists in memory as one buffer, so downloading a 90 MB kernel package
2186/// costs what downloading a shell script costs — the property the cache check
2187/// above and [`Pool::publish`] are both written for. And nothing unverified is
2188/// ever visible at the destination, because the rename is what puts it there;
2189/// the staging name is unique to this call, so a concurrent download of the same
2190/// package publishes its own file rather than consuming this one.
2191///
2192/// The staging file is not synced before the rename, for the reason
2193/// [`write_atomically`] gives: what is read back is verified against a digest,
2194/// so an entry left short by a crash is rejected and fetched again, which is
2195/// cheaper than syncing every download against a failure that costs only a
2196/// repeat.
2197///
2198/// `size` is the length the index recorded for the package, where one is known.
2199/// It travels with the request and bounds the staging file, so a mirror that
2200/// answers a request for a two-megabyte `.deb` with an endless body fills no
2201/// more disk than the package weighs. The digest still decides whether the
2202/// bytes are the package; this only decides how many of them are read.
2203///
2204/// Anything that fails takes the staging file with it, so a mirror that dies
2205/// mid-body leaves nothing for the next one to trip over.
2206fn fetch_verified(
2207    fetcher: &mut dyn Fetch,
2208    url: &str,
2209    expected: &str,
2210    size: Option<u64>,
2211    dest: &Path,
2212) -> Result<(), DebianError> {
2213    let staged = staging_path(dest);
2214
2215    // Created exclusively, which establishes that the name is this call's alone
2216    // and refuses a symlink pre-planted at it rather than writing through it.
2217    let file = std::fs::OpenOptions::new()
2218        .write(true)
2219        .create_new(true)
2220        .open(&staged)
2221        .map_err(DebianError::at("staging a package", &staged))?;
2222
2223    let mut sink = digest::DigestWriter::new(Algorithm::Sha256, file);
2224    let request = FetchRequest::new(url);
2225    let fetched = match size {
2226        Some(size) => {
2227            let mut capped = super::LimitedWriter::new(&mut sink, size);
2228            fetcher.fetch(&request.sized(size), &mut capped)
2229        }
2230        None => fetcher.fetch(&request, &mut sink),
2231    };
2232    let digested = sink
2233        .finish()
2234        .map_err(DebianError::at("writing a package", &staged));
2235
2236    let published = fetched
2237        .map_err(DebianError::Fetch)
2238        .and(digested)
2239        .and_then(|(actual, _size)| {
2240            if actual == expected {
2241                Ok(())
2242            } else {
2243                Err(DebianError::HashMismatch {
2244                    path: url.to_string(),
2245                    expected: expected.to_string(),
2246                    actual,
2247                })
2248            }
2249        })
2250        .and_then(|()| {
2251            std::fs::rename(&staged, dest).map_err(DebianError::at("publishing a package", dest))
2252        });
2253
2254    if published.is_err() {
2255        let _ = std::fs::remove_file(&staged);
2256    }
2257    published
2258}
2259
2260/// One `.deb` to acquire: what the download needs and nothing else.
2261///
2262/// The two sources of an install set project onto this — a fresh resolution's
2263/// [`Package`], and a [`Plan`]'s [`PlannedPackage`] — so the download,
2264/// the digest check, and the cache reuse are one path whichever produced it.
2265struct Wanted {
2266    /// The package name, for the progress report and the dpkg install order.
2267    name: String,
2268    /// The archive-recorded SHA-256, which is both the cache key and the check
2269    /// the download is held to.
2270    sha256: String,
2271    /// The pool path, relative to the mirror root.
2272    filename: String,
2273    /// The archive-recorded length of the `.deb`, which bounds what the
2274    /// download may spend. `None` where nothing states it.
2275    size: Option<u64>,
2276    /// Which repository serves it, as an index into the configured list.
2277    origin: usize,
2278}
2279
2280impl Wanted {
2281    /// Projects a freshly resolved package.
2282    fn from_package(package: &Package) -> Wanted {
2283        Wanted {
2284            name: package.name.clone(),
2285            sha256: package.sha256.clone(),
2286            filename: package.filename.clone(),
2287            size: package.size,
2288            origin: package.origin,
2289        }
2290    }
2291
2292    /// Projects a package a plan names.
2293    ///
2294    /// The plan's archive index is the repository index, which is what
2295    /// [`validate_plan`] holds the configuration to.
2296    ///
2297    /// A plan carries no size, because installing from one fetches no index to
2298    /// read it from — that omission is the whole of what a plan saves. Such a
2299    /// download is bounded by the fetcher's own ceiling instead, and verified
2300    /// against the plan's digest exactly as any other is.
2301    fn from_planned(package: &PlannedPackage) -> Wanted {
2302        Wanted {
2303            name: package.name.clone(),
2304            sha256: package.sha256.clone(),
2305            filename: package.filename.clone(),
2306            size: None,
2307            origin: package.archive,
2308        }
2309    }
2310}
2311
2312/// Stage one: lay out the merged-usr farm and every package's files, and
2313/// initialize the dpkg database.
2314///
2315/// Returns the derived [`StatOverride`] — one record per file a package ships
2316/// with non-root ownership. The file is not written here: dpkg rejects a
2317/// statoverride naming `root` until `base-passwd` has created `/etc/passwd`, so
2318/// configuration writes it after the base-passwd wave. An extract-only rootfs,
2319/// which never configures, does not carry it.
2320fn stage_extract(
2321    staging: &Path,
2322    architecture: &str,
2323    installables: &[Installable],
2324    archives_host: &Path,
2325    observer: &mut dyn DebianObserver,
2326) -> Result<StatOverride, ProvisionError> {
2327    let statoverride =
2328        extract_packages(staging, architecture, installables, archives_host, observer)?;
2329    init_dpkg_database(staging)?;
2330    Ok(statoverride)
2331}
2332
2333/// Lays out the merged-usr farm and every package's files into `staging`,
2334/// returning the derived [`StatOverride`].
2335///
2336/// The extraction half of [`stage_extract`], without the dpkg database
2337/// initialization. A layered build reuses it to extract an increment into an
2338/// overlay upper: the merged-usr symlinks are created there so a package
2339/// shipping a legacy top-level path resolves into `usr`, exactly as for a full
2340/// bootstrap, while the base's own dpkg database — seen through the overlay —
2341/// is left untouched, so no empty database is written over it.
2342fn extract_packages(
2343    staging: &Path,
2344    architecture: &str,
2345    installables: &[Installable],
2346    archives_host: &Path,
2347    observer: &mut dyn DebianObserver,
2348) -> Result<StatOverride, ProvisionError> {
2349    create_merged_usr(staging, architecture)?;
2350
2351    // Every `.deb` ships a `./` entry, so the tree being assembled would
2352    // otherwise take the mode and modification time of whichever package
2353    // happened to be extracted last. A rootfs root is `0755`, the mode
2354    // `base-files` itself ships, and stating it keeps the published directory
2355    // independent of both the package set and the caller's umask.
2356    let mut extraction = Extraction::new(staging)?.root_mode(ROOTFS_ROOT_MODE);
2357    let mut statoverride = StatOverride::default();
2358    // A statoverride record is one newline-terminated line; a path component
2359    // carrying an embedded newline would split into a second, forged record
2360    // (a setuid grant on an unrelated path, say). A conforming .deb never
2361    // ships such a path, and the signed digest chain keeps a mirror from
2362    // forging one, so this is defense in depth for an unreachable case: reject
2363    // rather than emit a line the framing cannot be trusted to contain.
2364    let mut malformed: Option<Vec<Vec<u8>>> = None;
2365    for installable in installables {
2366        // A package boundary again: the staging tree is discarded wholesale by
2367        // `ensure` when this returns an error, so a partial layout is not a
2368        // hazard.
2369        if observer.cancelled() {
2370            return Err(ProvisionError::Cancelled);
2371        }
2372        observer.progress(DebianEvent::Extracting {
2373            package: &installable.name,
2374        });
2375        let filename = installable
2376            .deb_path
2377            .rsplit('/')
2378            .next()
2379            .expect("the deb path has a file name");
2380        let path = archives_host.join(filename);
2381        let mut file = std::fs::File::open(&path)
2382            .map_err(|err| ProvisionError::io("reading the package", &path, err))?;
2383        // Re-verify against the digest the package was acquired under, rather
2384        // than trust the bytes on disk: the download and this read are separate
2385        // steps, and the cache may share a filesystem with untrusted writers.
2386        //
2387        // Streamed, and the extraction that follows streams too, so installing a
2388        // kernel package costs the memory of installing a shell script — the
2389        // property the download and `Pool::publish` both keep, held to here as
2390        // well. `extract_deb` rewinds before it seeks to the data member, so the
2391        // digest pass leaving the file at its end costs nothing.
2392        let (actual, _) = digest::stream(Algorithm::Sha256, &mut file)
2393            .map_err(|err| ProvisionError::io("reading the package", &path, err))?;
2394        if actual != installable.sha256 {
2395            return Err(wrap(DebianError::HashMismatch {
2396                path: filename.to_string(),
2397                expected: installable.sha256.clone(),
2398                actual,
2399            }));
2400        }
2401        deb::extract_deb(
2402            &installable.name,
2403            &mut file,
2404            &mut extraction,
2405            &mut |entry, components| {
2406                if entry.uid != 0 || entry.gid != 0 {
2407                    if components
2408                        .iter()
2409                        .any(|component| component.contains(&b'\n'))
2410                    {
2411                        malformed.get_or_insert_with(|| components.to_vec());
2412                        return Ok(Placement::Write);
2413                    }
2414                    statoverride.record(entry.mode, absolute_path(components));
2415                }
2416                // A `.deb`'s files are dpkg's to arbitrate, and this hook only
2417                // records ownership as they go past, so every entry is written.
2418                Ok(Placement::Write)
2419            },
2420        )?;
2421    }
2422    if let Some(components) = malformed {
2423        return Err(wrap(DebianError::MalformedEntry {
2424            path: absolute_path(&components),
2425        }));
2426    }
2427    extraction.finalize(staging)?;
2428    Ok(statoverride)
2429}
2430
2431/// Creates the merged-usr symlink farm for `architecture` in `staging`.
2432fn create_merged_usr(staging: &Path, architecture: &str) -> Result<(), ProvisionError> {
2433    for dir in arch::merged_usr_dirs(architecture) {
2434        let target = staging.join("usr").join(dir);
2435        std::fs::create_dir_all(&target)
2436            .map_err(|err| ProvisionError::io("creating", &target, err))?;
2437        let link = staging.join(dir);
2438        if !arch::exists(&link) {
2439            std::os::unix::fs::symlink(format!("usr/{dir}"), &link)
2440                .map_err(|err| ProvisionError::io("linking", &link, err))?;
2441        }
2442    }
2443    Ok(())
2444}
2445
2446/// Initializes the dpkg database: empty `status` and `available`, and the
2447/// working directories dpkg expects.
2448fn init_dpkg_database(staging: &Path) -> Result<(), ProvisionError> {
2449    let dpkg = staging.join("var/lib/dpkg");
2450    for dir in ["", "info", "updates"] {
2451        let path = dpkg.join(dir);
2452        std::fs::create_dir_all(&path).map_err(|err| ProvisionError::io("creating", &path, err))?;
2453    }
2454    for file in ["status", "available"] {
2455        let path = dpkg.join(file);
2456        if !path.exists() {
2457            std::fs::write(&path, "").map_err(|err| ProvisionError::io("writing", &path, err))?;
2458        }
2459    }
2460    Ok(())
2461}
2462
2463/// The maintainer-script environment: non-interactive, deterministic locale
2464/// and timezone.
2465fn maintainer_env() -> Vec<(String, String)> {
2466    [
2467        ("DEBIAN_FRONTEND", "noninteractive"),
2468        ("DEBCONF_NONINTERACTIVE_SEEN", "true"),
2469        ("LC_ALL", "C.UTF-8"),
2470        ("TZ", "UTC"),
2471    ]
2472    .into_iter()
2473    .map(|(name, value)| (name.to_string(), value.to_string()))
2474    .collect()
2475}
2476
2477/// Fetches a URL fully into memory, bounded by the size a verified source
2478/// declares for it where there is one.
2479///
2480/// The declared size travels with the request, so a transport that honours it
2481/// refuses an oversized framing before reading a byte, *and* bounds the sink
2482/// here, because a caller-supplied [`Fetch`] is free to ignore the request and
2483/// the sink is the one place the bound cannot be ignored. Without it the only
2484/// bound is the built-in fetcher's own `MAX_BODY` — two gigabytes — or nothing
2485/// at all for a transport of the caller's, while the release that named the
2486/// resource says it is a few megabytes. The digest check that follows catches
2487/// the substitution either way, but only after the memory has been spent.
2488fn fetch_bytes(
2489    fetcher: &mut dyn Fetch,
2490    url: &str,
2491    size: Option<u64>,
2492) -> Result<Vec<u8>, DebianError> {
2493    let mut bytes = Vec::new();
2494    let request = FetchRequest::new(url);
2495    let result = match size {
2496        Some(size) => {
2497            let mut sink = super::LimitedWriter::new(&mut bytes, size);
2498            fetcher.fetch(&request.sized(size), &mut sink)
2499        }
2500        None => fetcher.fetch(&request, &mut bytes),
2501    };
2502    result?;
2503    Ok(bytes)
2504}
2505
2506/// Verifies bytes against an expected SHA-256, naming the resource on
2507/// mismatch.
2508fn verify_digest(path: &str, bytes: &[u8], expected: &str) -> Result<(), DebianError> {
2509    let actual = Algorithm::Sha256.hex_of(bytes);
2510    if actual == expected {
2511        Ok(())
2512    } else {
2513        Err(DebianError::HashMismatch {
2514            path: path.to_string(),
2515            expected: expected.to_string(),
2516            actual,
2517        })
2518    }
2519}
2520
2521/// Decompresses a fetched index into text.
2522fn decompress_index(path: &str, bytes: &[u8]) -> Result<String, DebianError> {
2523    let mut cursor = io::Cursor::new(bytes);
2524    let head = super::compress::sniff_head(&mut cursor)
2525        .map_err(DebianError::at("reading the index", path))?;
2526    let mut reader =
2527        super::compress::decompress(&head, cursor).map_err(|_| DebianError::Index {
2528            reason: format!("{path} is not a valid index"),
2529        })?;
2530    let mut text = String::new();
2531    reader
2532        .read_to_string(&mut text)
2533        .map_err(DebianError::at("decompressing the index", path))?;
2534    Ok(text)
2535}
2536
2537/// Joins path components into an absolute path for a statoverride line.
2538///
2539/// Components are lossily decoded as UTF-8. Debian policy requires package
2540/// paths to be UTF-8, so a conforming archive round-trips exactly; a
2541/// non-UTF-8 path would be mangled here, which dpkg then rejects at
2542/// configuration rather than mis-applying an override.
2543fn absolute_path(components: &[Vec<u8>]) -> String {
2544    let mut path = String::from("/");
2545    for (i, component) in components.iter().enumerate() {
2546        if i > 0 {
2547            path.push('/');
2548        }
2549        path.push_str(&String::from_utf8_lossy(component));
2550    }
2551    path
2552}
2553
2554/// Boxes a [`DebianError`] into [`ProvisionError::Other`], the way a
2555/// provisioner outside the core surfaces its own failures.
2556fn wrap(error: DebianError) -> ProvisionError {
2557    ProvisionError::other(error)
2558}
2559
2560/// A progress and cancellation sink for a Debian bootstrap.
2561///
2562/// Attached with [`Debian::observe`], and shaped like
2563/// [`ProvisionObserver`](crate::provision::ProvisionObserver): one method for
2564/// events, one for cancellation, both with default bodies, so an observer
2565/// implements only the half it wants and a plain
2566/// `FnMut(DebianEvent<'_>)` closure is an observer that reports and never
2567/// cancels.
2568///
2569/// Cancelling is what the trait adds over the closure: a bootstrap fetches and
2570/// verifies indexes, downloads a hundred packages, and runs several `dpkg`
2571/// waves in a cage, and a caller that wants to stop it part-way could not
2572/// before.
2573///
2574/// # Example
2575///
2576/// ```no_run
2577/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2578/// use std::sync::Arc;
2579/// use std::sync::atomic::{AtomicBool, Ordering};
2580///
2581/// use ferroday_cage::provision::debian::{Debian, DebianEvent, DebianObserver};
2582///
2583/// struct Progress {
2584///     stop: Arc<AtomicBool>,
2585/// }
2586///
2587/// impl DebianObserver for Progress {
2588///     fn progress(&mut self, event: DebianEvent<'_>) {
2589///         if let DebianEvent::Downloading { package, index, total, .. } = event {
2590///             eprintln!("{package} ({index}/{total})");
2591///         }
2592///     }
2593///
2594///     fn cancelled(&mut self) -> bool {
2595///         self.stop.load(Ordering::Relaxed)
2596///     }
2597/// }
2598///
2599/// let stop = Arc::new(AtomicBool::new(false));
2600/// let mut debian = Debian::builder("trixie").build()?;
2601/// let plan = debian.observe(&mut Progress { stop: Arc::clone(&stop) }).resolve()?;
2602/// # let _ = plan;
2603/// # Ok(())
2604/// # }
2605/// ```
2606///
2607/// # Stability
2608///
2609/// Every method added to this trait in a later release will carry a default
2610/// body, so an existing implementation keeps compiling.
2611pub trait DebianObserver {
2612    /// Receives one progress event.
2613    fn progress(&mut self, event: DebianEvent<'_>) {
2614        let _ = event;
2615    }
2616
2617    /// Whether the bootstrap should stop.
2618    ///
2619    /// Consulted at the points where stopping is clean — a package boundary, an
2620    /// extraction boundary. Returning `true` aborts with
2621    /// [`ProvisionError::Cancelled`],
2622    /// and a run driven through [`provision::ensure`](crate::provision::ensure)
2623    /// then removes the staging tree, so a cancelled bootstrap leaves no
2624    /// destination behind.
2625    ///
2626    /// The default is `false`: a bootstrap that is never cancelled.
2627    ///
2628    /// [`ProvisionError::Cancelled`]: crate::provision::ProvisionError::Cancelled
2629    fn cancelled(&mut self) -> bool {
2630        false
2631    }
2632}
2633
2634/// A closure is an observer that reports and never cancels.
2635impl<F: FnMut(DebianEvent<'_>)> DebianObserver for F {
2636    fn progress(&mut self, event: DebianEvent<'_>) {
2637        self(event)
2638    }
2639}
2640
2641/// An observer that reports nowhere and never cancels: what an entry point the
2642/// caller did not attach a sink to uses.
2643struct Silent;
2644
2645impl DebianObserver for Silent {}
2646
2647/// An observer that reports nowhere and takes its cancellation from the
2648/// provisioning run: what an unobserved [`Debian`] used as a [`Provisioner`].
2649///
2650/// The events still reach the run's own observer, wrapped in the shared
2651/// vocabulary — that is the one channel every provisioner reports through.
2652struct RunObserver<'a, 'r> {
2653    request: &'a ProvisionRequest<'r>,
2654}
2655
2656impl DebianObserver for RunObserver<'_, '_> {
2657    fn progress(&mut self, event: DebianEvent<'_>) {
2658        self.request.report(ProvisionEvent::Debian(&event));
2659    }
2660
2661    fn cancelled(&mut self) -> bool {
2662        self.request.cancelled()
2663    }
2664}
2665
2666/// An observer that reports to a sink the caller bound with [`Debian::observe`]
2667/// and takes its cancellation from the provisioning run.
2668///
2669/// The caller chose the richer, Debian-specific channel for events; a
2670/// [`Provision::observe`](crate::provision::Provision::observe) observer can
2671/// still stop a bootstrap it is not reporting on.
2672struct ObservedRun<'a, 'r, 'o> {
2673    sink: &'o mut dyn DebianObserver,
2674    request: &'a ProvisionRequest<'r>,
2675}
2676
2677impl DebianObserver for ObservedRun<'_, '_, '_> {
2678    fn progress(&mut self, event: DebianEvent<'_>) {
2679        self.sink.progress(event);
2680    }
2681
2682    fn cancelled(&mut self) -> bool {
2683        self.request.cancelled()
2684    }
2685}
2686
2687/// A progress event from a Debian bootstrap.
2688#[derive(Debug)]
2689#[non_exhaustive]
2690pub enum DebianEvent<'a> {
2691    /// A URL is about to be fetched.
2692    ///
2693    /// The event announces an intent, not a transfer in flight. Where several
2694    /// resources are fetched at once, every URL in a batch is announced before
2695    /// any of them is asked for, so the order is the order they were queued in
2696    /// rather than the order they arrive.
2697    #[non_exhaustive]
2698    Fetching {
2699        /// The URL.
2700        url: &'a str,
2701    },
2702    /// The dependency closure is being resolved.
2703    Resolving,
2704    /// A dependency the resolver could not satisfy: a name no configured
2705    /// archive carries, a name it carries only at a version the constraint
2706    /// rules out, or one an exclusion removed that a hard dependency needed.
2707    ///
2708    /// Reported for every such refusal before the run fails, so a caller fixing
2709    /// an install list sees the whole list rather than the first entry of it.
2710    /// The failure that follows carries the same set as one sentence.
2711    #[non_exhaustive]
2712    Unsatisfiable {
2713        /// What could not be supplied: the dependency group as the archive
2714        /// declares it, alternatives included, or the package name where the
2715        /// caller named one directly.
2716        requirement: &'a str,
2717        /// Who asked for it: the selected package whose dependency it was, or
2718        /// the install list.
2719        required_by: &'a str,
2720        /// What the wall was, as the clause following "and": that the name is
2721        /// absent, that it is excluded, or what is available instead of what
2722        /// the constraint asked for.
2723        reason: &'a str,
2724    },
2725    /// The dependency closure has been resolved, carrying the plan the
2726    /// bootstrap will install.
2727    ///
2728    /// Emitted mid-bootstrap once resolution completes and before the first
2729    /// download, so a consumer observes the exact, archive-verified manifest a
2730    /// full [`provision`](super::ensure) installs without a separate
2731    /// [`Debian::resolve`] pass. Every digest in the plan chains back to the
2732    /// release signature, the same as [`Debian::resolve`] reports.
2733    #[non_exhaustive]
2734    Resolved {
2735        /// The plan the bootstrap will install.
2736        plan: &'a Plan,
2737    },
2738    /// A package is being downloaded.
2739    #[non_exhaustive]
2740    Downloading {
2741        /// The package name.
2742        package: &'a str,
2743        /// Its position in the download, from 1.
2744        index: usize,
2745        /// The total number of packages.
2746        total: usize,
2747    },
2748    /// A package's files are being extracted.
2749    #[non_exhaustive]
2750    Extracting {
2751        /// The package name.
2752        package: &'a str,
2753    },
2754    /// Output from a dpkg wave running inside the cage.
2755    #[non_exhaustive]
2756    CommandOutput {
2757        /// Which standard stream the bytes came from.
2758        stream: Stream,
2759        /// The raw output bytes; not line-buffered, not guaranteed UTF-8.
2760        bytes: &'a [u8],
2761    },
2762}
2763
2764/// An error from bootstrapping a Debian userland.
2765///
2766/// Surfaces through [`ProvisionError::Other`]; downcast to inspect it.
2767#[derive(Debug)]
2768#[non_exhaustive]
2769pub enum DebianError {
2770    /// The provisioner was misconfigured, or a host precondition (a foreign
2771    /// architecture's binfmt handler) is unmet.
2772    #[non_exhaustive]
2773    Config {
2774        /// What is wrong with the configuration.
2775        reason: String,
2776    },
2777    /// Fetching a resource failed.
2778    Fetch(FetchError),
2779    /// The archive signature was not accepted: it did not verify against the
2780    /// keyring, or the key or signature was refused on its own terms.
2781    #[non_exhaustive]
2782    Signature {
2783        /// Why the signature was not accepted.
2784        reason: String,
2785    },
2786    /// The release is malformed, expired, or does not offer what was asked.
2787    #[non_exhaustive]
2788    Release {
2789        /// What is wrong with the release.
2790        reason: String,
2791    },
2792    /// A package index is malformed or absent.
2793    #[non_exhaustive]
2794    Index {
2795        /// What is wrong with the index.
2796        reason: String,
2797    },
2798    /// A package shipped an entry whose path cannot be represented safely in
2799    /// the dpkg metadata derived from it.
2800    #[non_exhaustive]
2801    MalformedEntry {
2802        /// The offending path, lossily decoded.
2803        path: String,
2804    },
2805    /// The dependency closure could not be resolved.
2806    #[non_exhaustive]
2807    Resolve {
2808        /// Why the closure could not be resolved.
2809        reason: String,
2810    },
2811    /// A fetched resource did not match its recorded digest.
2812    #[non_exhaustive]
2813    HashMismatch {
2814        /// The resource path.
2815        path: String,
2816        /// The digest the release recorded.
2817        expected: String,
2818        /// The digest of what was fetched.
2819        actual: String,
2820    },
2821    /// A dpkg wave exited unsuccessfully.
2822    #[non_exhaustive]
2823    Configure {
2824        /// Which wave failed.
2825        wave: &'static str,
2826        /// dpkg's exit status.
2827        status: ExitStatus,
2828    },
2829    /// Packages were not left in the installed state after configuration.
2830    #[non_exhaustive]
2831    NotConfigured {
2832        /// The packages that did not reach `install ok installed`.
2833        packages: Vec<String>,
2834    },
2835    /// Launching a cage for a configuration wave failed.
2836    Launch(crate::Error),
2837    /// The pre-configure overlay could not be laid into the staging tree.
2838    Overlay(ProvisionError),
2839    /// A `.deb` presented to [`Pool::publish`] could not be read, or does not
2840    /// belong in the pool being written.
2841    #[non_exhaustive]
2842    Deb {
2843        /// The `.deb` file concerned.
2844        path: PathBuf,
2845        /// What was wrong with it.
2846        reason: String,
2847    },
2848    /// The archives could not supply what a
2849    /// [`pin`](DebianBuilder::pin) holds the resolution to.
2850    #[non_exhaustive]
2851    Pin {
2852        /// Every package the pin could not be held to, in name order, each
2853        /// carrying what the archives offer instead.
2854        unheld: Vec<UnheldPin>,
2855    },
2856    /// A [`Plan`] document could not be read, or a [`Plan`] could not be
2857    /// written as one.
2858    #[non_exhaustive]
2859    PlanDocument {
2860        /// What is wrong with the document, or with the plan that could not be
2861        /// rendered as one.
2862        reason: String,
2863    },
2864    /// A host I/O operation failed.
2865    #[non_exhaustive]
2866    Io {
2867        /// What the operation was doing.
2868        op: &'static str,
2869        /// The path concerned.
2870        path: PathBuf,
2871        /// The underlying error.
2872        source: io::Error,
2873    },
2874}
2875
2876impl DebianError {}
2877
2878path_io_error! {
2879    DebianError,
2880    /// This is the form the bootstrap's host-side steps use, where the path is
2881    /// composed from a contained tree and the operation is known before the
2882    /// call that might fail.
2883}
2884
2885impl fmt::Display for DebianError {
2886    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2887        match self {
2888            DebianError::Config { reason } => {
2889                write!(f, "the Debian bootstrap is misconfigured: {reason}")
2890            }
2891            DebianError::Fetch(err) => {
2892                write!(f, "the Debian bootstrap could not fetch a resource: {err}")
2893            }
2894            DebianError::Signature { reason } => {
2895                write!(f, "the archive signature was not accepted: {reason}")
2896            }
2897            DebianError::Release { reason } => write!(f, "the release file is unusable: {reason}"),
2898            DebianError::Index { reason } => write!(f, "a package index is unusable: {reason}"),
2899            DebianError::MalformedEntry { path } => {
2900                write!(f, "a package entry has an unrepresentable path: {path}",)
2901            }
2902            DebianError::Resolve { reason } => write!(f, "dependency resolution failed: {reason}"),
2903            DebianError::HashMismatch {
2904                path,
2905                expected,
2906                actual,
2907            } => write!(
2908                f,
2909                "{path} did not match its recorded digest (expected {expected}, got {actual})",
2910            ),
2911            DebianError::Configure { wave, status } => {
2912                write!(f, "the dpkg {wave} step exited with {status}")
2913            }
2914            DebianError::NotConfigured { packages } => write!(
2915                f,
2916                "these packages were not configured: {}",
2917                packages.join(", "),
2918            ),
2919            DebianError::Launch(err) => write!(f, "a configuration step could not launch: {err}"),
2920            DebianError::Overlay(err) => {
2921                write!(f, "the pre-configure overlay could not be applied: {err}")
2922            }
2923            DebianError::Deb { path, reason } => {
2924                write!(
2925                    f,
2926                    "the .deb {} cannot be published: {reason}",
2927                    path.display()
2928                )
2929            }
2930            DebianError::Pin { unheld } => {
2931                let held = unheld
2932                    .iter()
2933                    .map(UnheldPin::to_string)
2934                    .collect::<Vec<_>>()
2935                    .join("; ");
2936                write!(f, "the archives cannot hold the pin: {held}")
2937            }
2938            DebianError::PlanDocument { reason } => {
2939                write!(f, "the install plan document is not usable: {reason}")
2940            }
2941            DebianError::Io { op, path, source } => {
2942                write!(
2943                    f,
2944                    "a Debian bootstrap step failed while {op} {}: {source}",
2945                    path.display()
2946                )
2947            }
2948        }
2949    }
2950}
2951
2952impl std::error::Error for DebianError {
2953    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2954        match self {
2955            DebianError::Fetch(err) => Some(err),
2956            DebianError::Launch(err) => Some(err),
2957            DebianError::Overlay(err) => Some(err),
2958            DebianError::Io { source, .. } => Some(source),
2959            _ => None,
2960        }
2961    }
2962}
2963
2964impl From<FetchError> for DebianError {
2965    fn from(err: FetchError) -> Self {
2966        DebianError::Fetch(err)
2967    }
2968}
2969
2970impl From<release::ReleaseError> for DebianError {
2971    fn from(err: release::ReleaseError) -> Self {
2972        match err {
2973            release::ReleaseError::OpenPgp(err) => DebianError::Signature {
2974                reason: err.to_string(),
2975            },
2976            release::ReleaseError::Release(reason) => DebianError::Release { reason },
2977        }
2978    }
2979}
2980
2981impl From<crate::Error> for DebianError {
2982    fn from(err: crate::Error) -> Self {
2983        DebianError::Launch(err)
2984    }
2985}
2986
2987#[cfg(test)]
2988mod tests {
2989    use super::*;
2990    use crate::scratch::Scratch;
2991
2992    /// A transport that refuses the first mirror with a fixed error and serves
2993    /// a body from any other, recording every URL it was asked for.
2994    struct Failing {
2995        primary: String,
2996        refusal: fn(&str) -> FetchError,
2997        asked: Vec<String>,
2998    }
2999
3000    impl Fetch for Failing {
3001        fn fetch(
3002            &mut self,
3003            request: &FetchRequest<'_>,
3004            sink: &mut dyn std::io::Write,
3005        ) -> Result<(), FetchError> {
3006            self.asked.push(request.url().to_string());
3007            if request.url().starts_with(&self.primary) {
3008                return Err((self.refusal)(request.url()));
3009            }
3010            sink.write_all(b"from the backstop")
3011                .map_err(|err| FetchError::io("writing the body", request.url(), err))
3012        }
3013    }
3014
3015    /// A transport that serves one fixed body to whatever it is asked for.
3016    struct Canned(&'static [u8]);
3017
3018    impl Fetch for Canned {
3019        fn fetch(
3020            &mut self,
3021            request: &FetchRequest<'_>,
3022            sink: &mut dyn std::io::Write,
3023        ) -> Result<(), FetchError> {
3024            sink.write_all(self.0)
3025                .map_err(|err| FetchError::io("writing the body", request.url(), err))
3026        }
3027    }
3028
3029    /// A transport that answers each URL from a table, counting every call.
3030    ///
3031    /// The count is what the acquisition tests are about: whether a package the
3032    /// batch delivered is asked for a second time by the walk behind it.
3033    struct Table {
3034        bodies: std::collections::HashMap<String, Vec<u8>>,
3035        asked: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3036    }
3037
3038    impl Fetch for Table {
3039        fn fetch(
3040            &mut self,
3041            request: &FetchRequest<'_>,
3042            sink: &mut dyn std::io::Write,
3043        ) -> Result<(), FetchError> {
3044            self.asked
3045                .lock()
3046                .expect("the record is not poisoned")
3047                .push(request.url().to_string());
3048            match self.bodies.get(request.url()) {
3049                Some(body) => sink
3050                    .write_all(body)
3051                    .map_err(|err| FetchError::io("writing the body", request.url(), err)),
3052                None => Err(FetchError::not_found(request.url())),
3053            }
3054        }
3055    }
3056
3057    /// One package to acquire, and what the mirror will answer for it.
3058    fn wanted(name: &str, body: &[u8]) -> Wanted {
3059        Wanted {
3060            name: name.to_string(),
3061            sha256: Algorithm::Sha256.hex_of(body),
3062            filename: format!("pool/{name}.deb"),
3063            size: Some(body.len() as u64),
3064            origin: 0,
3065        }
3066    }
3067
3068    /// Acquires `packages` against a transport answering `bodies`, and reports
3069    /// every URL it was asked for.
3070    fn acquire(
3071        packages: &[Wanted],
3072        bodies: &[(String, Vec<u8>)],
3073        archives: &Path,
3074    ) -> (Result<Vec<Installable>, ProvisionError>, Vec<String>) {
3075        let repositories = vec![
3076            Repository::builder("trixie")
3077                // Acquisition takes a repository only for its mirror list, and
3078                // never reads its release; the scheme is what lets the builder
3079                // accept an unsigned one.
3080                .mirror("https://mirror.example/debian")
3081                .trust_unsigned(true)
3082                .build()
3083                .expect("the repository builds"),
3084        ];
3085        let asked = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3086        let mut fetcher = Table {
3087            bodies: bodies.iter().cloned().collect(),
3088            asked: std::sync::Arc::clone(&asked),
3089        };
3090        let outcome = acquire_debs(&mut fetcher, &repositories, packages, archives, &mut Silent);
3091        let urls = asked.lock().expect("the record is not poisoned").clone();
3092        (outcome, urls)
3093    }
3094
3095    #[test]
3096    fn a_package_the_batch_delivered_is_not_asked_for_again() {
3097        // The batch is an optimization over the walk, not a replacement for it,
3098        // so the two must not both fetch the same package: the walk asks only
3099        // for what is not in the cache when it runs.
3100        let scratch = Scratch::new("acquire-batched");
3101        let one = b"the first package".to_vec();
3102        let two = b"the second package".to_vec();
3103        let packages = vec![wanted("one", &one), wanted("two", &two)];
3104        let bodies = vec![
3105            (
3106                "https://mirror.example/debian/pool/one.deb".to_string(),
3107                one,
3108            ),
3109            (
3110                "https://mirror.example/debian/pool/two.deb".to_string(),
3111                two,
3112            ),
3113        ];
3114
3115        let (installables, asked) = acquire(&packages, &bodies, scratch.as_ref());
3116        let installables = installables.expect("both packages arrive");
3117        assert_eq!(installables.len(), 2);
3118        assert_eq!(asked.len(), 2, "each package was fetched once: {asked:?}");
3119        for package in &packages {
3120            assert!(
3121                scratch.join(format!("{}.deb", package.sha256)).is_file(),
3122                "{} should be in the cache",
3123                package.name,
3124            );
3125        }
3126    }
3127
3128    #[test]
3129    fn a_package_the_batch_could_not_deliver_falls_to_the_walk() {
3130        // Nothing about the batch decides that a package is present. A job that
3131        // does not arrive leaves the cache without it, and the walk behind --
3132        // which has every mirror rather than the first -- fetches it as though
3133        // the batch had not run.
3134        let scratch = Scratch::new("acquire-batch-miss");
3135        let one = b"the first package".to_vec();
3136        let two = b"the second package".to_vec();
3137        let packages = vec![wanted("one", &one), wanted("two", &two)];
3138        // The transport answers `one` and knows nothing of `two`, so the batch
3139        // delivers one of the two and the walk asks again for the other.
3140        let bodies = vec![(
3141            "https://mirror.example/debian/pool/one.deb".to_string(),
3142            one.clone(),
3143        )];
3144
3145        let (outcome, asked) = acquire(&packages, &bodies, scratch.as_ref());
3146        assert!(outcome.is_err(), "no mirror serves the second package");
3147        assert_eq!(
3148            asked.iter().filter(|url| url.ends_with("/two.deb")).count(),
3149            2,
3150            "the batch asked once and the walk asked again: {asked:?}",
3151        );
3152        assert!(
3153            scratch
3154                .join(format!("{}.deb", packages[0].sha256))
3155                .is_file()
3156        );
3157    }
3158
3159    #[test]
3160    fn bytes_the_batch_could_not_verify_are_not_published() {
3161        // The rename is what decides a package is in the cache, and it happens
3162        // only over bytes whose digest matched. A mirror that serves the wrong
3163        // body through the batch therefore leaves nothing behind, and the walk
3164        // refuses it for the same reason.
3165        let scratch = Scratch::new("acquire-batch-wrong");
3166        let package = wanted("one", b"the package the index named");
3167        let bodies = vec![(
3168            "https://mirror.example/debian/pool/one.deb".to_string(),
3169            b"something else entirely".to_vec(),
3170        )];
3171
3172        let (outcome, asked) = acquire(std::slice::from_ref(&package), &bodies, scratch.as_ref());
3173        assert!(outcome.is_err(), "the bytes are not the package");
3174        assert!(
3175            !scratch.join(format!("{}.deb", package.sha256)).is_file(),
3176            "nothing unverified reaches the cache",
3177        );
3178        // Nothing is left staged either.
3179        let leftovers: Vec<_> = std::fs::read_dir(scratch.as_ref())
3180            .expect("the cache directory is readable")
3181            .filter_map(Result::ok)
3182            .map(|entry| entry.file_name())
3183            .collect();
3184        assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
3185        assert_eq!(asked.len(), 2, "the batch asked, then the walk did");
3186    }
3187
3188    #[test]
3189    fn a_verified_release_reports_the_mirror_the_digest_and_the_signing_key() {
3190        // The three facts a plan's archive record carries that the release body
3191        // does not itself state, checked against a real Debian release signed by
3192        // the embedded keyring. The hermetic repository fixtures are unsigned,
3193        // so this is where the signed branch's wiring is proven.
3194        const INRELEASE: &[u8] =
3195            include_bytes!("../../../tests/fixtures/debian-archive/forky-InRelease");
3196        // The repository builder takes a keyring by path, so the embedded one
3197        // goes to a file the test owns.
3198        let scratch = Scratch::new("release-provenance");
3199        let keyring_path = scratch.join("archive-keyring.gpg");
3200        std::fs::write(&keyring_path, EMBEDDED_KEYRING).expect("the keyring is writable");
3201
3202        let repository = Repository::builder("forky")
3203            .mirror("https://deb.example/debian")
3204            .keyring(&keyring_path)
3205            // The fixture is a real release with a real expiry, long past.
3206            .allow_stale_release(true)
3207            .build()
3208            .expect("the repository validates");
3209
3210        let (release, served) = fetch_release(&mut Canned(INRELEASE), &repository, &mut Silent)
3211            .expect("the embedded keyring verifies a real Debian release");
3212
3213        assert_eq!(served.mirror, "https://deb.example/debian");
3214        assert_eq!(served.signed_by.len(), 1, "{:?}", served.signed_by);
3215        assert_eq!(served.signed_by[0].len(), 40, "{:?}", served.signed_by);
3216
3217        // The digest is over the bytes the signature covered, which is not the
3218        // InRelease the transport delivered: the armor and the signature are
3219        // around what was verified rather than part of it.
3220        let (message, _) = pgp::composed::CleartextSignedMessage::from_armor(INRELEASE)
3221            .expect("the fixture is a cleartext-signed document");
3222        assert_eq!(
3223            release.sha256(),
3224            Algorithm::Sha256.hex_of(message.signed_text().as_bytes()),
3225        );
3226        assert_ne!(release.sha256(), Algorithm::Sha256.hex_of(INRELEASE));
3227
3228        // And it is a property of those bytes rather than of the fetch, so a
3229        // second pass over the same release records the same digest.
3230        let (again, _) = fetch_release(&mut Canned(INRELEASE), &repository, &mut Silent)
3231            .expect("the same release verifies again");
3232        assert_eq!(release.sha256(), again.sha256());
3233
3234        assert_eq!(release.date(), Some("Tue, 21 Jul 2026 14:11:47 UTC"));
3235        assert_eq!(release.valid_until(), Some("Tue, 28 Jul 2026 14:11:47 UTC"));
3236    }
3237
3238    /// What a mirror walk produced: the body, and the mirror that served it.
3239    type Walked = Result<(Vec<u8>, String), DebianError>;
3240
3241    /// Walks a two-mirror list whose primary answers with `refusal`, returning
3242    /// the outcome and the URLs the walk asked for.
3243    fn walk_past(refusal: fn(&str) -> FetchError) -> (Walked, Vec<String>) {
3244        let mut fetcher = Failing {
3245            primary: "https://primary.example".to_string(),
3246            refusal,
3247            asked: Vec::new(),
3248        };
3249        let mirrors = [
3250            "https://primary.example/debian".to_string(),
3251            "https://snapshot.example/debian".to_string(),
3252        ];
3253        let result =
3254            fetch_from_mirrors(&mut fetcher, &mirrors, "dists/stable/Release", &mut Silent);
3255        (result, fetcher.asked)
3256    }
3257
3258    #[test]
3259    fn the_mirror_walk_advances_past_a_failing_mirror() {
3260        // Every way a mirror can decline to serve is a reason to try the next
3261        // one: the backstop exists for precisely the outage a 5xx reports, and
3262        // a CDN answers an absent object with 403 as readily as with 404.
3263        for refusal in [
3264            |url: &str| FetchError::not_found(url),
3265            |url: &str| FetchError::status(url, 503),
3266            |url: &str| FetchError::status(url, 403),
3267            |url: &str| {
3268                FetchError::io(
3269                    "connecting",
3270                    url,
3271                    std::io::Error::from(std::io::ErrorKind::ConnectionRefused),
3272                )
3273            },
3274        ] {
3275            let (result, asked) = walk_past(refusal);
3276            let (bytes, mirror) = result.expect("the backstop answers");
3277            assert_eq!(bytes, b"from the backstop", "{asked:?}");
3278            // And the walk reports the mirror that served, not the one that was
3279            // asked first: a plan resolved this way records which mirror
3280            // answered, which is the provenance a reader of the plan wants and
3281            // not a decision about where the packages are later fetched from.
3282            assert_eq!(mirror, "https://snapshot.example/debian", "{asked:?}");
3283            assert_eq!(asked.len(), 2, "both mirrors were tried: {asked:?}");
3284        }
3285    }
3286
3287    #[test]
3288    fn the_mirror_walk_stops_at_a_malformed_url() {
3289        // A URL the transport cannot parse is the caller's configuration, not
3290        // the mirror's state; trying the next mirror would bury the mistake.
3291        let (result, asked) = walk_past(|url| FetchError::url(url, "unsupported scheme"));
3292        assert!(
3293            matches!(result, Err(DebianError::Fetch(FetchError::Url { .. }))),
3294            "{asked:?}",
3295        );
3296        assert_eq!(asked.len(), 1, "the walk stopped at the first mirror");
3297        // And it is reported against the mirror that carries the bad URL rather
3298        // than against the backstop, which is the whole reason a `Url` failure
3299        // does not advance the walk.
3300        let reason = result.unwrap_err().to_string();
3301        assert!(reason.contains("primary.example"), "{reason}");
3302        assert!(!reason.contains("snapshot.example"), "{reason}");
3303    }
3304
3305    #[test]
3306    fn an_exhausted_walk_is_reported_against_the_mirror_it_asked_last() {
3307        // The other attribution: with every mirror declining, the answer names
3308        // the one the walk ended on. Keeping the last failure is what makes the
3309        // diagnostic survive a walk, and reporting the first would point at a
3310        // mirror that may be perfectly healthy.
3311        let mut fetcher = Failing {
3312            // Every mirror below starts with this, so all of them refuse.
3313            primary: "https://".to_string(),
3314            refusal: |url: &str| FetchError::status(url, 503),
3315            asked: Vec::new(),
3316        };
3317        let mirrors = [
3318            "https://first.example/debian".to_string(),
3319            "https://last.example/debian".to_string(),
3320        ];
3321        let result =
3322            fetch_from_mirrors(&mut fetcher, &mirrors, "dists/stable/Release", &mut Silent);
3323        assert_eq!(fetcher.asked.len(), 2, "{:?}", fetcher.asked);
3324        let reason = result.unwrap_err().to_string();
3325        assert!(reason.contains("last.example"), "{reason}");
3326        assert!(!reason.contains("first.example"), "{reason}");
3327    }
3328
3329    #[test]
3330    fn the_embedded_keyring_parses() {
3331        // The vendored keyring must be a valid binary OpenPGP keyring that
3332        // rPGP can read; parsing succeeds only with at least one usable
3333        // (unrevoked) key.
3334        Keyring::parse(EMBEDDED_KEYRING).expect("the embedded keyring parses");
3335    }
3336
3337    #[test]
3338    fn unsigned_input_does_not_verify() {
3339        // A release body with no cleartext signature is refused, not accepted
3340        // as if unsigned content were trusted.
3341        let keyring = Keyring::parse(EMBEDDED_KEYRING).unwrap();
3342        assert!(
3343            keyring
3344                .verify(b"Suite: trixie\nComponents: main\n", true)
3345                .is_err()
3346        );
3347    }
3348
3349    #[test]
3350    fn the_builder_requires_a_suite() {
3351        // A whitespace-only suite is rejected. `Debian` is not `Debug`
3352        // (it holds trait objects), so this matches rather than unwraps.
3353        assert!(matches!(
3354            Debian::builder("  ").build(),
3355            Err(DebianError::Config { .. })
3356        ));
3357    }
3358
3359    #[test]
3360    fn the_builder_defaults_are_applied() {
3361        let debian = Debian::builder("trixie").build().unwrap();
3362        // A single-mirror bootstrap has exactly the primary repository, carrying
3363        // the default mirror, components, suite, and a signed trust anchor.
3364        assert_eq!(debian.repositories.len(), 1);
3365        let primary = &debian.repositories[0];
3366        assert_eq!(primary.mirrors, [DEFAULT_MIRROR]);
3367        assert_eq!(primary.components, ["main"]);
3368        assert_eq!(primary.suite, "trixie");
3369        assert!(!primary.trust_unsigned());
3370        assert!(!debian.architecture.is_empty());
3371    }
3372
3373    #[test]
3374    fn the_builder_carries_the_base_priority_and_excludes() {
3375        // The base priority defaults to required and the exclude list to empty;
3376        // both builder methods carry their values through to the provisioner.
3377        let default = Debian::builder("trixie").build().unwrap();
3378        assert_eq!(default.base_priority, Priority::Required);
3379        assert!(default.excludes.is_empty());
3380
3381        let configured = Debian::builder("trixie")
3382            .base_priority(Priority::Important)
3383            .exclude(["isc-dhcp-client", "dhcpcd-base"])
3384            .build()
3385            .unwrap();
3386        assert_eq!(configured.base_priority, Priority::Important);
3387        assert_eq!(configured.excludes, ["isc-dhcp-client", "dhcpcd-base"]);
3388    }
3389
3390    #[test]
3391    fn an_unsigned_http_primary_is_refused() {
3392        // The primary repository enforces the same unsigned-over-http refusal
3393        // the flattened configuration did.
3394        assert!(matches!(
3395            Debian::builder("trixie")
3396                .mirror("http://deb.debian.org/debian")
3397                .trust_unsigned(true)
3398                .build(),
3399            Err(DebianError::Config { .. })
3400        ));
3401    }
3402
3403    #[test]
3404    fn an_additional_repository_follows_the_primary() {
3405        let extra = Repository::builder("trixie")
3406            .mirror("file:///srv/local-debs")
3407            .trust_unsigned(true)
3408            .name("local")
3409            .build()
3410            .expect("the repository validates");
3411        let debian = Debian::builder("trixie")
3412            .repository(extra)
3413            .build()
3414            .expect("the builder validates");
3415        assert_eq!(debian.repositories.len(), 2);
3416        // The primary is element zero; the additional repository follows.
3417        assert_eq!(debian.repositories[0].mirrors, [DEFAULT_MIRROR]);
3418        assert_eq!(debian.repositories[1].name.as_deref(), Some("local"));
3419    }
3420
3421    #[test]
3422    fn components_join_with_a_paragraph_break_between_them() {
3423        // An index ends with the last field of its last stanza, not with a
3424        // blank line, so joining two components end to end would fold the
3425        // second's first stanza into the first's last one. Nothing downstream
3426        // would report it: each component verifies against the release on its
3427        // own, and only the concatenation is wrong, so the packages that stanza
3428        // named simply stop existing.
3429        let mut merged = String::new();
3430        append_component(&mut merged, "Package: a\nVersion: 1\n");
3431        append_component(&mut merged, "Package: b\nVersion: 2\n");
3432        assert_eq!(merged, "Package: a\nVersion: 1\n\nPackage: b\nVersion: 2\n");
3433        let stanzas = crate::provision::document::parse(&merged);
3434        let names: Vec<_> = stanzas
3435            .iter()
3436            .filter_map(|stanza| stanza.get("Package"))
3437            .collect();
3438        assert_eq!(names, ["a", "b"], "a component's stanza was absorbed");
3439
3440        // The join is the same however a component's own file terminates, so a
3441        // producer that does end its index with a blank line -- or with several
3442        // -- does not open a gap in the merged text.
3443        let mut spaced = String::new();
3444        append_component(&mut spaced, "Package: a\nVersion: 1\n\n\n");
3445        append_component(&mut spaced, "Package: b\nVersion: 2");
3446        assert_eq!(spaced, merged);
3447
3448        // An empty component contributes nothing, and does not leave a stray
3449        // paragraph break behind: a pool declared before the build that fills
3450        // it is published empty, and is a component like any other.
3451        let mut with_empty = String::new();
3452        append_component(&mut with_empty, "");
3453        append_component(&mut with_empty, "Package: a\nVersion: 1\n");
3454        append_component(&mut with_empty, "   \n");
3455        assert_eq!(with_empty, "Package: a\nVersion: 1\n");
3456    }
3457
3458    /// An uncompressed tar of `(path, mode, uid, gid)` entries: the shape of a
3459    /// `.deb`'s `data.tar`. A path ending in `/` is a directory, which is how
3460    /// the oldest writers marked one and how the parser reads it — so the
3461    /// entries are written as regular files and the trailing slash is what the
3462    /// reader keys on.
3463    fn data_tar(entries: &[(&str, u32, u32, u32)]) -> Vec<u8> {
3464        let mut tar = ferroday_cage_testkit::tar::Tar::new();
3465        for (path, mode, uid, gid) in entries {
3466            tar.file_owned(path, *mode, *uid, *gid, b"");
3467        }
3468        tar.finish()
3469    }
3470
3471    /// A `.deb` around a `data.tar`, with the `debian-binary` member that
3472    /// precedes it. No control member: extraction never reads one.
3473    fn deb_of(data: &[u8]) -> Vec<u8> {
3474        ferroday_cage_testkit::deb::ar(&[("debian-binary", b"2.0\n"), ("data.tar", data)])
3475    }
3476
3477    /// Writes each `.deb` into `archives` under its digest, as the download
3478    /// does, and returns the install set naming them.
3479    fn installables(archives: &Path, debs: &[(&str, Vec<u8>)]) -> Vec<Installable> {
3480        std::fs::create_dir_all(archives).expect("the archive directory is creatable");
3481        debs.iter()
3482            .map(|(name, bytes)| {
3483                let sha256 = Algorithm::Sha256.hex_of(bytes);
3484                std::fs::write(archives.join(format!("{sha256}.deb")), bytes)
3485                    .expect("the package is writable");
3486                Installable {
3487                    name: (*name).to_string(),
3488                    deb_path: format!("{}/{sha256}.deb", bootstrap::ARCHIVES),
3489                    sha256,
3490                }
3491            })
3492            .collect()
3493    }
3494
3495    #[test]
3496    fn two_packages_shipping_one_owned_path_derive_one_statoverride_record() {
3497        // dpkg treats two records for one path as an unrecoverable fatal error,
3498        // and a closure reaches that honestly: the resolver reads no Conflicts,
3499        // so two packages that a real archive expects never to be unpacked
3500        // together can both be selected, and tar permits one data.tar to list a
3501        // path twice besides. Both shapes are here, and both have to collapse.
3502        let dir = Scratch::new("statoverride-duplicate");
3503        let archives = dir.join("archives");
3504        let staging = dir.join("staging");
3505        std::fs::create_dir_all(&staging).expect("the staging tree is creatable");
3506
3507        let first = deb_of(&data_tar(&[("./usr/sbin/helper", 0o2755, 0, 42)]));
3508        let second = deb_of(&data_tar(&[
3509            // The same path a second package already shipped, at another mode.
3510            ("./usr/sbin/helper", 0o4755, 0, 42),
3511            // And a path this one lists twice within its own data member.
3512            ("./usr/bin/tool", 0o2755, 0, 42),
3513            ("./usr/bin/tool", 0o755, 0, 42),
3514        ]));
3515        let install = installables(&archives, &[("first", first), ("second", second)]);
3516
3517        let overrides = extract_packages(&staging, "amd64", &install, &archives, &mut Silent)
3518            .expect("both packages extract");
3519
3520        // One record per path, each carrying the mode of the file that survived
3521        // — the last entry to describe it, which is the extraction's own rule.
3522        assert_eq!(
3523            overrides.render(),
3524            "root root 755 /usr/bin/tool\nroot root 4755 /usr/sbin/helper\n",
3525        );
3526    }
3527
3528    #[test]
3529    fn the_rootfs_root_takes_the_bootstrap_s_mode_and_not_a_package_s() {
3530        // Every `.deb` ships a `./` entry, so a tree assembled from several
3531        // would otherwise take the mode of whichever was extracted last — an
3532        // arbitrary choice among packages that describe their own contents and
3533        // say nothing about the tree being assembled from them. A package
3534        // shipping `./` as 0700 would leave a rootfs the caller has to chmod
3535        // before anything can traverse it.
3536        use std::os::unix::fs::PermissionsExt;
3537
3538        let dir = Scratch::new("rootfs-root-mode");
3539        let archives = dir.join("archives");
3540        let staging = dir.join("staging");
3541        std::fs::create_dir_all(&staging).expect("the staging tree is creatable");
3542
3543        let closed = deb_of(&data_tar(&[
3544            ("./", 0o700, 0, 0),
3545            ("./usr/bin/x", 0o755, 0, 0),
3546        ]));
3547        let install = installables(&archives, &[("closed", closed)]);
3548        extract_packages(&staging, "amd64", &install, &archives, &mut Silent)
3549            .expect("the package extracts");
3550
3551        let mode = std::fs::metadata(&staging).unwrap().permissions().mode() & 0o7777;
3552        assert_eq!(mode, ROOTFS_ROOT_MODE, "the package chose the root's mode");
3553    }
3554
3555    /// The names a directory holds, sorted.
3556    fn entries_of(dir: &Path) -> Vec<String> {
3557        let mut names: Vec<String> = std::fs::read_dir(dir)
3558            .expect("the directory is readable")
3559            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
3560            .collect();
3561        names.sort();
3562        names
3563    }
3564
3565    #[test]
3566    fn a_download_publishes_only_what_matches_the_recorded_digest() {
3567        // The bytes are written to a staging file as they arrive and digested on
3568        // the way through, so nothing is held in memory and nothing unverified
3569        // is ever visible at the destination: the rename is what puts it there.
3570        let dir = Scratch::new("download-verified");
3571        let dest = dir.join("package.deb");
3572
3573        let err = fetch_verified(
3574            &mut Canned(b"not what the archive recorded"),
3575            "file:///ignored-by-the-canned-transport",
3576            &Algorithm::Sha256.hex_of(b"the recorded bytes"),
3577            None,
3578            &dest,
3579        )
3580        .expect_err("a mismatched download is refused");
3581        assert!(matches!(err, DebianError::HashMismatch { .. }), "{err}");
3582        assert!(!dest.exists(), "unverified bytes were published");
3583        assert!(
3584            entries_of(&dir).is_empty(),
3585            "a failed download left its staging file behind: {:?}",
3586            entries_of(&dir),
3587        );
3588
3589        fetch_verified(
3590            &mut Canned(b"the recorded bytes"),
3591            "file:///ignored-by-the-canned-transport",
3592            &Algorithm::Sha256.hex_of(b"the recorded bytes"),
3593            Some(b"the recorded bytes".len() as u64),
3594            &dest,
3595        )
3596        .expect("a matching download publishes");
3597        assert_eq!(std::fs::read(&dest).unwrap(), b"the recorded bytes");
3598        assert_eq!(entries_of(&dir), ["package.deb"]);
3599    }
3600
3601    #[test]
3602    fn a_download_stops_at_the_size_the_index_recorded() {
3603        // The index records what the package weighs, so a mirror answering with
3604        // more is answering with something else. A transport of the caller's is
3605        // free to ignore the size the request declares, so the bound is applied
3606        // at the sink too — which is the one place it cannot be ignored — and
3607        // the disk it would have filled is never written.
3608        let dir = Scratch::new("download-capped");
3609        let dest = dir.join("package.deb");
3610
3611        let err = fetch_verified(
3612            &mut Canned(&[b'x'; 4096]),
3613            "file:///ignored-by-the-canned-transport",
3614            &Algorithm::Sha256.hex_of(&[b'x'; 4096]),
3615            Some(16),
3616            &dest,
3617        )
3618        .expect_err("a body over the recorded size is refused");
3619        assert!(
3620            matches!(err, DebianError::Fetch(FetchError::Io { .. })),
3621            "{err}"
3622        );
3623        assert!(!dest.exists(), "an oversized download was published");
3624        assert!(
3625            entries_of(&dir).is_empty(),
3626            "a refused download left its staging file behind: {:?}",
3627            entries_of(&dir),
3628        );
3629    }
3630}