deb,cli,test: add --mirror to build and resolve from a pinned mirror

Pinned runners (a caching proxy, a chosen mirror) must resolve every
package from one base URL. 'pkh deb --mirror <url>' passes the mirror
to mmdebstrap as its mirror positional, so the chroot tarball's apt
sources carry it from the bootstrap on and every apt inside the build
follows - no per-build source rewriting. The mirror must serve every
suite and architecture the build resolves (the security pocket and,
for cross builds, the target archive included); that contract is
documented, not enforced.

The tarball cache is keyed per mirror (a short sha prefix before the
'-buildd.tar.xz' suffix prune matches): a --mirror run over a stale
distro-default tarball would silently resolve from the distro, and
'--incremental' records must never fork across a mirror change.

The runtime source passes recognize the pinned URL as an archive
source (it carries no official host for the host-based checks): the
pocket pass and the universe pass treat it as the archive, and the
cross setup gives it every component and suite, no arch scoping (the
mirror serves every architecture by contract) and no ports entry.
'pkh test' passes no mirror: its tarballs stay distro-default.
This commit is contained in:
2026-09-30 00:04:50 +02:00
parent 8cf9489072
commit 6385cf6dc1
9 changed files with 201 additions and 32 deletions
+85 -15
View File
@@ -4,6 +4,7 @@ use crate::deb::{Phase, enter_phase};
use crate::interrupt::CleanupHookGuard;
use crate::report::BuildView;
use directories::ProjectDirs;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::fs;
use std::os::unix::io::AsRawFd;
@@ -251,19 +252,42 @@ fn is_mountpoint(path: &Path) -> bool {
/// The cached chroot tarball path for a series/arch, as used by the
/// bootstrap: the record's fork gate compares the tarball's digest
/// against the sha recorded in a base's manifest.
/// against the sha recorded in a base's manifest. A mirror pin is hashed
/// into the name (before the `-buildd.tar.xz` suffix prune matches), so
/// a pinned tarball never collides with the distro-default one — a
/// `--mirror` run over a stale distro tarball would silently resolve
/// from the distro, exactly the lie the pin must not tell.
pub(crate) fn chroot_tarball_path(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
) -> Result<PathBuf, Box<dyn Error>> {
let proj_dirs =
ProjectDirs::from("com", "pkh", "pkh").ok_or("Could not determine project directories")?;
let tarball_filename = if let Some(a) = arch {
format!("{}-{}-buildd.tar.xz", series, a)
} else {
format!("{}-buildd.tar.xz", series)
Ok(proj_dirs
.cache_dir()
.join(chroot_tarball_filename(series, arch, mirror)))
}
/// The cached chroot tarball's file name for a series/arch/mirror
pub(crate) fn chroot_tarball_filename(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
) -> String {
let target = match arch {
Some(a) => format!("{series}-{a}"),
None => series.to_string(),
};
Ok(proj_dirs.cache_dir().join(tarball_filename))
match mirror {
None => format!("{target}-buildd.tar.xz"),
Some(m) => {
let mut hasher = Sha256::new();
hasher.update(m.as_bytes());
let hash = format!("{:x}", hasher.finalize());
format!("{target}-{}-buildd.tar.xz", &hash[..12])
}
}
}
/// Root of the shared apt download caches (`/var/tmp/pkh/apt-cache`),
@@ -490,10 +514,11 @@ impl EphemeralContextGuard {
pub async fn new_with_context(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
) -> Result<Self, Box<dyn Error>> {
Self::new_internal(None, series, arch, base_ctx, view, false, None).await
Self::new_internal(None, series, arch, mirror, base_ctx, view, false, None).await
}
/// Create the ephemeral build context inside `session`'s chroot.
@@ -511,10 +536,12 @@ impl EphemeralContextGuard {
/// * `ccache_dir` - The shared ccache directory to mount at the
/// chroot's `/var/cache/pkh-ccache` (`pkh deb` computes it from the
/// build identity; `None` leaves the chroot unshared)
#[allow(clippy::too_many_arguments)]
pub async fn new_for_session(
session: Session,
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
keep_upper: bool,
@@ -524,6 +551,7 @@ impl EphemeralContextGuard {
Some(session),
series,
arch,
mirror,
base_ctx,
view,
keep_upper,
@@ -532,10 +560,12 @@ impl EphemeralContextGuard {
.await
}
#[allow(clippy::too_many_arguments)]
async fn new_internal(
session: Option<Session>,
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
keep_upper: bool,
@@ -603,6 +633,7 @@ impl EphemeralContextGuard {
let apt_cache_lock = match Self::download_and_extract_chroot(
series,
arch,
mirror,
&chroot_path,
base_ctx.clone(),
view,
@@ -633,7 +664,7 @@ impl EphemeralContextGuard {
// cache to decide whether the record's staged tree is still
// reusable.
if let Some(s) = &session
&& let Ok(tarball) = chroot_tarball_path(series, arch)
&& let Ok(tarball) = chroot_tarball_path(series, arch, mirror)
&& let Ok(sha) = session::sha256_file(&tarball)
{
s.set_tarball_sha256(&sha);
@@ -683,6 +714,7 @@ impl EphemeralContextGuard {
async fn download_and_extract_chroot(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
view: &dyn BuildView,
@@ -700,12 +732,7 @@ impl EphemeralContextGuard {
let cache_dir = proj_dirs.cache_dir();
fs::create_dir_all(cache_dir)?;
// Create tarball filename based on series and architecture
let tarball_filename = if let Some(a) = arch {
format!("{}-{}-buildd.tar.xz", series, a)
} else {
format!("{}-buildd.tar.xz", series)
};
let tarball_filename = chroot_tarball_filename(series, arch, mirror);
let tarball_path = cache_dir.join(&tarball_filename);
// Check for existing lockfile, and wait for a timeout if it exists
@@ -752,7 +779,7 @@ impl EphemeralContextGuard {
arch
);
enter_phase(view, Phase::PreparingChroot);
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
Self::download_chroot_tarball(series, arch, mirror, &tarball_path, ctx, view).await?;
} else {
log::debug!(
"Using cached chroot tarball for {} (arch: {:?})",
@@ -917,6 +944,7 @@ impl EphemeralContextGuard {
async fn download_chroot_tarball(
series: &str,
arch: Option<&str>,
mirror: Option<&str>,
tarball_path: &Path,
ctx: Arc<context::Context>,
view: &dyn BuildView,
@@ -953,6 +981,14 @@ impl EphemeralContextGuard {
cmd.arg(series)
.arg(tarball_path.to_string_lossy().to_string());
// The mirror positional: without it mmdebstrap writes the distro
// defaults into the tarball's apt sources verbatim; with it, the
// tarball's sources.list carries the mirror, so every apt inside
// the build follows.
if let Some(m) = mirror {
cmd.arg(m);
}
if let Some(s) = view.sink() {
cmd.capture(s);
}
@@ -1287,6 +1323,40 @@ impl Drop for EphemeralContextGuard {
mod chroot_cleanup_tests {
use super::*;
/// The tarball cache name: per series/arch as before, and a distinct
/// deterministic name per pinned mirror — hashed BEFORE the
/// `-buildd.tar.xz` suffix, which is the pattern prune matches (a
/// pinned tarball pruning like any other).
#[test]
fn tarball_filename_keys_the_mirror_in() {
assert_eq!(
chroot_tarball_filename("noble", None, None),
"noble-buildd.tar.xz"
);
assert_eq!(
chroot_tarball_filename("noble", Some("riscv64"), None),
"noble-riscv64-buildd.tar.xz"
);
let pinned =
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu"));
assert!(pinned.starts_with("noble-amd64-"));
assert!(pinned.ends_with("-buildd.tar.xz"));
// Deterministic, and distinct per mirror (and from the unpinned).
assert_eq!(
pinned,
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu"))
);
assert_ne!(
pinned,
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3143/ubuntu"))
);
assert_ne!(
pinned,
chroot_tarball_filename("noble", Some("amd64"), None)
);
}
/// /proc/mounts path fields use octal escapes for whitespace and
/// backslashes; anything else must be kept verbatim.
#[test]