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}