context,deb: run the rules as a nested-namespace build identity
Rootless builds handed the staged tree to the build user with a recursive chown; through the overlay staging every lower-layer file the chown touched copy-ups — the full data movement of the tree, 19% of a kernel build's wall clock. The rules steps are now flagged and the unshare driver enters the chroot by pivot_root instead of chroot(2), then creates a nested user namespace mapping the context's root onto a fixed build id: the chroot's files are the invoking user's host-side and the context maps that user to uid 0, so the staged tree is owned by the build identity by construction — no chown, no data movement. The kernel refuses user namespace creation to chrooted processes, hence the pivot, and the bind is recursive and taken inside the namespace: a plain bind of the chroot's host-made submounts fails EINVAL on locked children, while the rbind carries them across the pivot, and a fresh /proc is mounted before the old root is shed (umount resolves through /proc/mounts). fakeroot's chown wrappers attempt the real chown and swallow only EPERM; as the mapped identity, root targets are unmapped and fail EINVAL, so mapped builds export FAKEROOTDONTTRYCHOWN — fakeroot's own knob — and the fake ownership is still recorded. Files the build writes come out as the invoking user host-side, which also fixes the silently missing .buildinfo/.changes of rootless builds: the host-side rewrite of debian/files hit a subuid-owned file with EACCES. The wrapper is probed per build; when nested namespaces are unavailable (old util-linux, userns disabled), the previous setpriv plus selective parallel chown handover runs unchanged. A setpriv-era staged tree is handed to the invoking user once, host-side and metadata-only, before the overlay mounts it — afterwards overlayfs serves stale owners from its attribute cache.
This commit is contained in:
@@ -41,6 +41,26 @@ pub struct OverlayStaging {
|
|||||||
pub overlay: bool,
|
pub overlay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker environment value (set to `1`) flagging a command as a rules
|
||||||
|
/// step of a rootless build: unshare-based contexts then enter the chroot
|
||||||
|
/// by pivoting and drop into a nested user namespace mapping the chroot's
|
||||||
|
/// root ownership onto the build identity ([`MAPPED_BUILD_ID`]), instead
|
||||||
|
/// of chrooting into the namespace's root — so the staged tree is already
|
||||||
|
/// owned and no chown is needed. See `crate::deb::local`, which sets it.
|
||||||
|
pub(crate) const MAPPED_IDENTITY_ENV: &str = "PKH_MAPPED_IDENTITY";
|
||||||
|
|
||||||
|
/// The build identity inside the nested namespace of a rootless build:
|
||||||
|
/// the namespace maps the chroot's root onto this id, and the chroot's
|
||||||
|
/// passwd/group entries carry it, so `getpwuid` resolves the identity for
|
||||||
|
/// the build tools. It must stay outside the context namespace's own
|
||||||
|
/// mapping (which covers 0..=65535), so the two identities never alias.
|
||||||
|
pub(crate) const MAPPED_BUILD_ID: u32 = 65536;
|
||||||
|
|
||||||
|
const _: () = assert!(
|
||||||
|
MAPPED_BUILD_ID > 65535,
|
||||||
|
"the mapped identity must not alias a context-namespace uid"
|
||||||
|
);
|
||||||
|
|
||||||
/// A ContextDriver is the interface for the logic happening inside a context
|
/// A ContextDriver is the interface for the logic happening inside a context
|
||||||
pub trait ContextDriver {
|
pub trait ContextDriver {
|
||||||
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf>;
|
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod ssh;
|
|||||||
mod unshare;
|
mod unshare;
|
||||||
|
|
||||||
pub use api::{Context, ContextCommand, ContextConfig, LineSink, OverlayStaging, Stream};
|
pub use api::{Context, ContextCommand, ContextConfig, LineSink, OverlayStaging, Stream};
|
||||||
|
pub(crate) use api::{MAPPED_BUILD_ID, MAPPED_IDENTITY_ENV};
|
||||||
// The driver trait is implementation detail of the context API; it is only
|
// The driver trait is implementation detail of the context API; it is only
|
||||||
// needed crate-internally (test-run capture wrapper), so keep it out of the
|
// needed crate-internally (test-run capture wrapper), so keep it out of the
|
||||||
// public surface (and its documentation requirement).
|
// public surface (and its documentation requirement).
|
||||||
|
|||||||
+188
-14
@@ -1,4 +1,6 @@
|
|||||||
use super::api::{Context, ContextCommand, ContextDriver, LineSink};
|
use super::api::{
|
||||||
|
Context, ContextCommand, ContextDriver, LineSink, MAPPED_BUILD_ID, MAPPED_IDENTITY_ENV,
|
||||||
|
};
|
||||||
use super::shell::shell_quote;
|
use super::shell::shell_quote;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -534,6 +536,9 @@ impl UnshareDriver {
|
|||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
cwd: Option<&str>,
|
cwd: Option<&str>,
|
||||||
) -> ContextCommand<'_> {
|
) -> ContextCommand<'_> {
|
||||||
|
let mapped = env
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key == MAPPED_IDENTITY_ENV && value == "1");
|
||||||
let mut cmd = self.parent().command("unshare");
|
let mut cmd = self.parent().command("unshare");
|
||||||
|
|
||||||
cmd.envs(env.iter().cloned());
|
cmd.envs(env.iter().cloned());
|
||||||
@@ -548,25 +553,112 @@ impl UnshareDriver {
|
|||||||
.arg("--map-auto")
|
.arg("--map-auto")
|
||||||
.arg("-r")
|
.arg("-r")
|
||||||
.arg("--mount")
|
.arg("--mount")
|
||||||
.arg("--fork")
|
.arg("--fork");
|
||||||
.arg("-R")
|
|
||||||
.arg(&self.path);
|
|
||||||
|
|
||||||
if let Some(dir) = cwd {
|
if mapped {
|
||||||
cmd.arg("-w").arg(dir);
|
// The rules steps of a rootless build (see the marker in
|
||||||
|
// `crate::deb::local`): no chroot(2) — the wrapper pivots into
|
||||||
|
// the chroot and only then creates the nested build-identity
|
||||||
|
// namespace, because a user namespace cannot be created from a
|
||||||
|
// chrooted process (the kernel refuses with EPERM) and
|
||||||
|
// chroot(2) cannot run inside one. The cwd is taken inside the
|
||||||
|
// script, after the pivot.
|
||||||
|
cmd.arg("--").arg("bash").arg("-c").arg(build_pivot_script(
|
||||||
|
&self.path,
|
||||||
|
cwd,
|
||||||
|
program,
|
||||||
|
args,
|
||||||
|
MAPPED_BUILD_ID,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
cmd.arg("-R").arg(&self.path);
|
||||||
|
|
||||||
|
if let Some(dir) = cwd {
|
||||||
|
cmd.arg("-w").arg(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the bash command: set up /dev/pts and run the program
|
||||||
|
// /proc should already be bind-mounted from the host before entering the namespace
|
||||||
|
cmd.arg("--")
|
||||||
|
.arg("bash")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(build_namespace_script(program, args));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the bash command: set up /dev/pts and run the program
|
|
||||||
// /proc should already be bind-mounted from the host before entering the namespace
|
|
||||||
cmd.arg("--")
|
|
||||||
.arg("bash")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(build_namespace_script(program, args));
|
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the rules-mode wrapper script: pivot into the chroot, bring up
|
||||||
|
/// `/proc` and `/dev/pts` inside it, then drop into the nested
|
||||||
|
/// build-identity namespace (mapping the namespace's root onto
|
||||||
|
/// `build_id`) and exec `program`.
|
||||||
|
///
|
||||||
|
/// The pivot replaces chroot(2): it keeps the process root at the root of
|
||||||
|
/// the mount namespace, which is what makes the nested user namespace
|
||||||
|
/// creatable at all. The bind is recursive (`--rbind`) and taken fresh
|
||||||
|
/// inside the namespace: a plain bind of a tree carrying mounts made
|
||||||
|
/// outside the namespace (the chroot's `/proc`, apt-cache and ccache
|
||||||
|
/// binds) is denied with EINVAL — its locked children would have to move
|
||||||
|
/// with it — while a recursive bind carries them, which is exactly what
|
||||||
|
/// the rules need to keep working after the pivot. A bind made outside
|
||||||
|
/// the namespace would also carry MNT_LOCKED and fail with EINVAL. The
|
||||||
|
/// fresh `/proc` must be mounted before the old root is unmounted: umount
|
||||||
|
/// resolves its target through /proc/mounts, which lives on the old root.
|
||||||
|
///
|
||||||
|
/// Everything before the pivot runs as the namespace's root with the
|
||||||
|
/// host's binaries and paths; everything after it resolves inside the
|
||||||
|
/// chroot with the chroot's own binaries (plain `unshare`, an Essential
|
||||||
|
/// tool). The program and every argument are POSIX-shell-quoted (see
|
||||||
|
/// [`shell_quote`]).
|
||||||
|
fn build_pivot_script(
|
||||||
|
chroot: &str,
|
||||||
|
cwd: Option<&str>,
|
||||||
|
program: &str,
|
||||||
|
args: &[String],
|
||||||
|
build_id: u32,
|
||||||
|
) -> String {
|
||||||
|
let chroot = shell_quote(chroot);
|
||||||
|
let mut script = format!(
|
||||||
|
"set -e; \
|
||||||
|
mount --make-rprivate /; \
|
||||||
|
mount --rbind {chroot} {chroot}; \
|
||||||
|
cd {chroot}; \
|
||||||
|
putold=$(mktemp -d .pkh-pivot.XXXXXX); \
|
||||||
|
pivot_root . \"$putold\"; \
|
||||||
|
mount -t proc proc /proc; \
|
||||||
|
umount -l \"$putold\"; \
|
||||||
|
rmdir \"$putold\" 2>/dev/null || true; \
|
||||||
|
mkdir -p /dev/pts; \
|
||||||
|
mount -t devpts devpts /dev/pts 2>/dev/null || true; \
|
||||||
|
touch /dev/ptmx; \
|
||||||
|
mount --bind /dev/pts/ptmx /dev/ptmx 2>/dev/null || true; "
|
||||||
|
);
|
||||||
|
|
||||||
|
// The nested namespace's command line, itself shell-quoted once as the
|
||||||
|
// `sh -c` argument: the cwd is taken inside the new root, then the
|
||||||
|
// program runs exec'd, as the build identity. Note that the identity
|
||||||
|
// is the only mapped id: uid/gid 0 stay unmapped, and the build env
|
||||||
|
// carries FAKEROOTDONTTRYCHOWN for the binary step — fakeroot's chown
|
||||||
|
// wrappers attempt the real chown and swallow only EPERM, so an
|
||||||
|
// unmapped root target would fail them with EINVAL (dh_fixperms
|
||||||
|
// chowns to root:root).
|
||||||
|
let mut inner = String::new();
|
||||||
|
if let Some(dir) = cwd {
|
||||||
|
inner.push_str(&format!("cd {}; ", shell_quote(dir)));
|
||||||
|
}
|
||||||
|
inner.push_str(&format!("exec {}", shell_quote(program)));
|
||||||
|
for arg in args {
|
||||||
|
inner.push(' ');
|
||||||
|
inner.push_str(&shell_quote(arg));
|
||||||
|
}
|
||||||
|
script.push_str(&format!(
|
||||||
|
"exec unshare --user --fork --map-user={build_id} --map-group={build_id} -- sh -c {}",
|
||||||
|
shell_quote(&inner)
|
||||||
|
));
|
||||||
|
script
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the shell script executed by `bash -c` inside the user namespace:
|
/// Build the shell script executed by `bash -c` inside the user namespace:
|
||||||
/// bring up `/dev/pts`, then run `program` with `args`.
|
/// bring up `/dev/pts`, then run `program` with `args`.
|
||||||
///
|
///
|
||||||
@@ -590,7 +682,7 @@ fn build_namespace_script(program: &str, args: &[String]) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_namespace_script;
|
use super::{MAPPED_BUILD_ID, build_namespace_script, build_pivot_script, shell_quote};
|
||||||
|
|
||||||
fn tail_after_devpts_setup(script: &str) -> &str {
|
fn tail_after_devpts_setup(script: &str) -> &str {
|
||||||
script
|
script
|
||||||
@@ -639,4 +731,86 @@ mod tests {
|
|||||||
let script = build_namespace_script("prog", &[String::new(), "x".to_string()]);
|
let script = build_namespace_script("prog", &[String::new(), "x".to_string()]);
|
||||||
assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'");
|
assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pivot script pivots before creating the nested namespace (a
|
||||||
|
/// user namespace cannot be created from a chrooted process, so no
|
||||||
|
/// chroot(2) may precede it), mounts a fresh /proc before shedding the
|
||||||
|
/// old root (umount resolves through /proc/mounts), and execs the
|
||||||
|
/// program as the mapped build identity.
|
||||||
|
#[test]
|
||||||
|
fn pivot_script_pivots_then_maps_the_identity() {
|
||||||
|
let script = build_pivot_script(
|
||||||
|
"/var/tmp/pkh/chroot",
|
||||||
|
Some("/tmp/pkh-build/tree"),
|
||||||
|
"debian/rules",
|
||||||
|
&["build".to_string()],
|
||||||
|
MAPPED_BUILD_ID,
|
||||||
|
);
|
||||||
|
// pivot strictly before the nested unshare
|
||||||
|
let pivot = script.find("pivot_root").expect("pivot step");
|
||||||
|
let nested = script.find("unshare --user").expect("nested step");
|
||||||
|
assert!(pivot < nested, "{script}");
|
||||||
|
assert!(script.contains("mount -t proc proc /proc;"), "{script}");
|
||||||
|
assert!(script.contains("umount -l"), "{script}");
|
||||||
|
assert!(
|
||||||
|
script.contains(&format!("--map-user={MAPPED_BUILD_ID}")),
|
||||||
|
"{script}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// the inner command line is the single shell-quoted `sh -c`
|
||||||
|
// argument: cwd inside the new root, then the exec'd program
|
||||||
|
let inner = format!(
|
||||||
|
"cd {}; exec {} {}",
|
||||||
|
shell_quote("/tmp/pkh-build/tree"),
|
||||||
|
shell_quote("debian/rules"),
|
||||||
|
shell_quote("build")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
script.ends_with(&format!("sh -c {}", shell_quote(&inner))),
|
||||||
|
"{script}"
|
||||||
|
);
|
||||||
|
// and the whole script must parse as shell
|
||||||
|
std::process::Command::new("bash")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&script)
|
||||||
|
.status()
|
||||||
|
.expect("run bash")
|
||||||
|
.success()
|
||||||
|
.then_some(())
|
||||||
|
.expect("the pivot script must parse: {script}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths and arguments with shell metacharacters survive the two
|
||||||
|
/// quoting levels (outer script, inner `sh -c`).
|
||||||
|
#[test]
|
||||||
|
fn pivot_script_quotes_the_chroot_and_the_command() {
|
||||||
|
let script = build_pivot_script(
|
||||||
|
"/tmp/some'chroot",
|
||||||
|
None,
|
||||||
|
"fakeroot",
|
||||||
|
&["debian/rules".to_string(), "b'n".to_string()],
|
||||||
|
MAPPED_BUILD_ID,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
script.contains(&format!("cd {}", shell_quote("/tmp/some'chroot"))),
|
||||||
|
"{script}"
|
||||||
|
);
|
||||||
|
let inner = format!(
|
||||||
|
"exec {} {} {}",
|
||||||
|
shell_quote("fakeroot"),
|
||||||
|
shell_quote("debian/rules"),
|
||||||
|
shell_quote("b'n")
|
||||||
|
);
|
||||||
|
assert!(script.contains(&shell_quote(&inner)), "{script}");
|
||||||
|
std::process::Command::new("bash")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&script)
|
||||||
|
.status()
|
||||||
|
.expect("run bash")
|
||||||
|
.success()
|
||||||
|
.then_some(())
|
||||||
|
.expect("the pivot script must parse: {script}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+298
-39
@@ -7,6 +7,7 @@ use crate::report::BuildView;
|
|||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -96,17 +97,105 @@ pub(crate) fn apply_ccache_env(env: &mut HashMap<String, String>, enabled: bool,
|
|||||||
/// (ssh, schroot) keep running the rules as their own user.
|
/// (ssh, schroot) keep running the rules as their own user.
|
||||||
pub const BUILD_USER: &str = "pkh-build";
|
pub const BUILD_USER: &str = "pkh-build";
|
||||||
|
|
||||||
|
/// How the rules steps run as an unprivileged build identity.
|
||||||
|
enum RulesIdentity {
|
||||||
|
/// A nested user namespace maps the chroot's root ownership onto
|
||||||
|
/// [`crate::context::MAPPED_BUILD_ID`]: the whole staged tree — the
|
||||||
|
/// chroot's files are the invoking user's host-side, and the context
|
||||||
|
/// namespace maps that user to uid 0 — shows as already owned by the
|
||||||
|
/// build identity, so nothing is chowned and no data moves. Files the
|
||||||
|
/// build writes come out as the invoking user's own host-side.
|
||||||
|
MappedNamespace,
|
||||||
|
/// Fallback when the nested namespace is unavailable: `setpriv` drops
|
||||||
|
/// to an ordinary chroot user, and the tree is handed over with a
|
||||||
|
/// selective parallel chown. Carries the (uid, gid).
|
||||||
|
Setpriv(String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the build context can establish the nested build-identity
|
||||||
|
/// namespace: kernel user namespaces reachable, a new enough util-linux
|
||||||
|
/// (`--map-user`), the pivot wrapper working, and the mapping actually
|
||||||
|
/// taken. The probe runs the real wrapper over a `sh` test, so a failure
|
||||||
|
/// here means the fallback would fail the same way.
|
||||||
|
fn probe_mapped_namespace(ctx: &Arc<Context>) -> Result<(), String> {
|
||||||
|
let mut cmd = ctx.command("sh");
|
||||||
|
cmd.env(crate::context::MAPPED_IDENTITY_ENV, "1")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!(
|
||||||
|
"test \"$(id -u)\" = {} && test \"$(id -g)\" = {}",
|
||||||
|
crate::context::MAPPED_BUILD_ID,
|
||||||
|
crate::context::MAPPED_BUILD_ID
|
||||||
|
));
|
||||||
|
match cmd.output() {
|
||||||
|
Ok(out) if out.status.success() => Ok(()),
|
||||||
|
Ok(out) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
Err(if stderr.trim().is_empty() {
|
||||||
|
format!("exit status {}", out.status)
|
||||||
|
} else {
|
||||||
|
stderr.trim().to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure [`BUILD_USER`] exists in the build context and owns everything
|
/// Ensure [`BUILD_USER`] exists in the build context and owns everything
|
||||||
/// the rules must write: the staged tree (whose parent is where
|
/// the rules must write: the staged tree (whose parent is where
|
||||||
/// dh_builddeb drops the .debs), the scratch HOME and the shared ccache
|
/// dh_builddeb drops the .debs), the scratch HOME and the shared ccache
|
||||||
/// directory. Returns the (uid, gid) for the setpriv invocation. Ownership
|
/// directory. Returns how the rules drop privileges.
|
||||||
/// is re-applied on every build: a resumed build stages the tree fresh
|
///
|
||||||
/// from the host, where everything is root-owned again.
|
/// The mapped namespace is the ownership by construction: the chroot's
|
||||||
|
/// files are the invoking user's host-side and the context maps that user
|
||||||
|
/// to uid 0, so a namespace nested inside the context's, mapping uid 0
|
||||||
|
/// onto [`crate::context::MAPPED_BUILD_ID`], makes the build identity the
|
||||||
|
/// owner of everything without moving a byte. The fallback creates an
|
||||||
|
/// ordinary chroot user and hands the tree over with a chown — through
|
||||||
|
/// the overlay staging every chowned lower-layer file copy-ups, which on
|
||||||
|
/// a kernel tree costs minutes (see [`hand_over_script`]).
|
||||||
fn ensure_build_user(
|
fn ensure_build_user(
|
||||||
ctx: &Arc<Context>,
|
ctx: &Arc<Context>,
|
||||||
workroot: &Path,
|
workroot: &Path,
|
||||||
|
session: Option<&session::Session>,
|
||||||
|
build_resume: bool,
|
||||||
ccache_dir: Option<&str>,
|
ccache_dir: Option<&str>,
|
||||||
) -> Result<(String, String), Box<dyn Error>> {
|
ccache_host_dir: Option<&Path>,
|
||||||
|
) -> Result<RulesIdentity, Box<dyn Error>> {
|
||||||
|
let mut mapped = match probe_mapped_namespace(ctx) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(reason) => {
|
||||||
|
log::info!(
|
||||||
|
"Nested user namespaces are unavailable in the build \
|
||||||
|
context ({reason}); falling back to the chown handover"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A setpriv-era upperdir must already have been handed over by the
|
||||||
|
// pre-staging migration (`migrate_upper_ownership`, called before the
|
||||||
|
// overlay mounts): chowning it now, behind the live mount, would leave
|
||||||
|
// stale-owned entries behind the overlay's attribute cache. If it is
|
||||||
|
// still foreign-owned the migration failed — take the chown fallback,
|
||||||
|
// whose in-chroot chown needs no host escalation.
|
||||||
|
if mapped
|
||||||
|
&& let Some(upper) = resumed_upper_dir(session, build_resume)
|
||||||
|
&& has_foreign_owner(&upper)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"the staged tree carries setpriv-era ownership the pre-staging \
|
||||||
|
migration could not hand over; falling back to the chown \
|
||||||
|
handover"
|
||||||
|
);
|
||||||
|
mapped = false;
|
||||||
|
}
|
||||||
|
if mapped {
|
||||||
|
if let Some(dir) = ccache_host_dir.filter(|dir| dir.is_dir()) {
|
||||||
|
migrate_ccache_ownership(dir);
|
||||||
|
}
|
||||||
|
write_build_user_entries(ctx, crate::context::MAPPED_BUILD_ID)?;
|
||||||
|
return Ok(RulesIdentity::MappedNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
// Through sh with the stderr dropped: a missing user is the expected
|
// Through sh with the stderr dropped: a missing user is the expected
|
||||||
// state in a fresh chroot, not a message the build log needs.
|
// state in a fresh chroot, not a message the build log needs.
|
||||||
let id_of = |ctx: &Arc<Context>, what: &str| {
|
let id_of = |ctx: &Arc<Context>, what: &str| {
|
||||||
@@ -145,7 +234,162 @@ fn ensure_build_user(
|
|||||||
if let Some(dir) = ccache_dir {
|
if let Some(dir) = ccache_dir {
|
||||||
let _ = hand_over(ctx, Path::new(dir), &ids);
|
let _ = hand_over(ctx, Path::new(dir), &ids);
|
||||||
}
|
}
|
||||||
Ok(ids)
|
Ok(RulesIdentity::Setpriv(ids.0, ids.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the passwd and group entries naming [`BUILD_USER`] at `id` —
|
||||||
|
/// the identity of the mapped namespace (and of the setpriv fallback when
|
||||||
|
/// the chroot carries no useradd). Minimal mmdebstrap chroots carry no
|
||||||
|
/// passwd package, so the entries are appended directly, as sbuild's
|
||||||
|
/// build-user setup does.
|
||||||
|
fn write_build_user_entries(ctx: &Arc<Context>, id: u32) -> Result<(), Box<dyn Error>> {
|
||||||
|
// Through sh with the stderr dropped: a missing entry is the expected
|
||||||
|
// state in a fresh chroot, not a message the build log needs.
|
||||||
|
let known = ctx
|
||||||
|
.command("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("id -u {BUILD_USER} 2>/dev/null"))
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|out| out.status.success());
|
||||||
|
if known {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for (file, entry) in [
|
||||||
|
("/etc/group", format!("{BUILD_USER}:x:{id}:")),
|
||||||
|
(
|
||||||
|
"/etc/passwd",
|
||||||
|
format!("{BUILD_USER}:x:{id}:{id}:/tmp/pkh-home:/usr/sbin/nologin"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let status = ctx
|
||||||
|
.command("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("echo {} >> {file}", shlex_quote(&entry)))
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("cannot append to {file}: {e}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("cannot append the {BUILD_USER} entry to {file}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host-side path of the staged tree's persistent overlay upperdir,
|
||||||
|
/// when this build resumes over one carrying setpriv-era files. Any other
|
||||||
|
/// staging mounts a fresh (empty) upper or a plain copy, and the merge
|
||||||
|
/// shows the host tree's own (invoking-user) files.
|
||||||
|
fn resumed_upper_dir(session: Option<&session::Session>, build_resume: bool) -> Option<PathBuf> {
|
||||||
|
build_resume
|
||||||
|
.then_some(session)
|
||||||
|
.flatten()
|
||||||
|
.map(|s| s.upper_dir())
|
||||||
|
.filter(|dir| dir.is_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any entry under `root` is not owned by the invoking user
|
||||||
|
/// host-side — the mark of a setpriv-era build (the old identity was a
|
||||||
|
/// chroot-local uid, host-side a subuid). The metadata is hot when the
|
||||||
|
/// resume sync just walked the same tree.
|
||||||
|
fn has_foreign_owner(root: &Path) -> bool {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let euid = unsafe { libc::geteuid() };
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(entries) = fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(meta) = fs::symlink_metadata(entry.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if meta.uid() != euid {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand a setpriv-era staged tree to the invoking user (one recursive,
|
||||||
|
/// host-side, metadata-only chown): the ownership the mapped-namespace
|
||||||
|
/// build identity writes with.
|
||||||
|
///
|
||||||
|
/// This MUST run before the overlay staging mounts the upperdir. Overlayfs
|
||||||
|
/// snapshots the owner into its overlay inode at first lookup, and its
|
||||||
|
/// permission checks consult that snapshot — a chown applied behind a live
|
||||||
|
/// mount leaves every previously-looked-up entry stale-owned, and the
|
||||||
|
/// rules (running as the mapped identity) fail with EACCES on files the
|
||||||
|
/// host side swears they own. On the unmounted upperdir the same chown is
|
||||||
|
/// a plain metadata pass, never a data copy.
|
||||||
|
pub(crate) fn migrate_upper_ownership(session: &session::Session) -> Result<(), Box<dyn Error>> {
|
||||||
|
let Some(upper) = session.upper_dir().is_dir().then_some(session.upper_dir()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if !has_foreign_owner(&upper) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
||||||
|
log::info!(
|
||||||
|
"Handing '{}' to the invoking user once (setpriv-era ownership, \
|
||||||
|
metadata only)",
|
||||||
|
upper.display()
|
||||||
|
);
|
||||||
|
let owner = format!("{}:{}", unsafe { libc::geteuid() }, unsafe {
|
||||||
|
libc::getegid()
|
||||||
|
});
|
||||||
|
let status = crate::deb::ephemeral::privileged_command("chown", is_root)
|
||||||
|
.arg("-R")
|
||||||
|
.arg(owner)
|
||||||
|
.arg(&upper)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("cannot hand over '{}': {e}", upper.display()))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot hand over '{}' (ownership escalation failed)",
|
||||||
|
upper.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time migration of the shared ccache to the invoking user's
|
||||||
|
/// host-side ownership, keyed on a marker inside the cache so its walk
|
||||||
|
/// runs once per cache, not once per build. The ccache is bind-mounted
|
||||||
|
/// (no overlay attribute caching), so this is safe at any point of the
|
||||||
|
/// build.
|
||||||
|
fn migrate_ccache_ownership(ccache_host_dir: &Path) {
|
||||||
|
let marker = ccache_host_dir.join(".pkh-ownership");
|
||||||
|
if marker.is_file() || !has_foreign_owner(ccache_host_dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
||||||
|
log::info!(
|
||||||
|
"Handing the shared ccache '{}' to the invoking user once \
|
||||||
|
(setpriv-era ownership, metadata only)",
|
||||||
|
ccache_host_dir.display()
|
||||||
|
);
|
||||||
|
let owner = format!("{}:{}", unsafe { libc::geteuid() }, unsafe {
|
||||||
|
libc::getegid()
|
||||||
|
});
|
||||||
|
let status = crate::deb::ephemeral::privileged_command("chown", is_root)
|
||||||
|
.arg("-R")
|
||||||
|
.arg(owner)
|
||||||
|
.arg(ccache_host_dir)
|
||||||
|
.status();
|
||||||
|
if status.is_ok_and(|s| s.success()) {
|
||||||
|
let _ = fs::write(&marker, "mapped\n");
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"cannot hand the shared ccache '{}' over; its shard \
|
||||||
|
directories stay unwritable and compiles fall back to \
|
||||||
|
running without cache hits",
|
||||||
|
ccache_host_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shell pipeline handing `dir` to the build identity: chown only the
|
/// The shell pipeline handing `dir` to the build identity: chown only the
|
||||||
@@ -183,9 +427,9 @@ fn hand_over(ctx: &Arc<Context>, dir: &Path, ids: &(String, String)) -> Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create [`BUILD_USER`] in the build context. Minimal mmdebstrap chroots
|
/// Create [`BUILD_USER`] in the build context (the setpriv fallback).
|
||||||
/// carry no passwd package (no useradd), so the passwd and group entries
|
/// Chroots carrying the passwd package take the useradd path; minimal
|
||||||
/// are appended directly when needed, as sbuild's build-user setup does.
|
/// mmdebstrap ones append the entries directly at a fixed, free id.
|
||||||
fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> {
|
fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> {
|
||||||
let have_useradd = ctx
|
let have_useradd = ctx
|
||||||
.command("sh")
|
.command("sh")
|
||||||
@@ -224,24 +468,7 @@ fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> {
|
|||||||
let uid = (64500..=65500)
|
let uid = (64500..=65500)
|
||||||
.find(|id| !used(&passwd, *id) && !used(&group, *id))
|
.find(|id| !used(&passwd, *id) && !used(&group, *id))
|
||||||
.ok_or("no free uid for the build user")?;
|
.ok_or("no free uid for the build user")?;
|
||||||
for (file, entry) in [
|
write_build_user_entries(ctx, uid)
|
||||||
("/etc/group", format!("{BUILD_USER}:x:{uid}:")),
|
|
||||||
(
|
|
||||||
"/etc/passwd",
|
|
||||||
format!("{BUILD_USER}:x:{uid}:{uid}:/tmp/pkh-home:/usr/sbin/nologin"),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let status = ctx
|
|
||||||
.command("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(format!("echo {} >> {file}", shlex_quote(&entry)))
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("cannot append to {file}: {e}"))?;
|
|
||||||
if !status.success() {
|
|
||||||
return Err(format!("cannot append the {BUILD_USER} entry to {file}").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-quote a string for /bin/sh.
|
/// Single-quote a string for /bin/sh.
|
||||||
@@ -249,17 +476,26 @@ fn shlex_quote(value: &str) -> String {
|
|||||||
format!("'{}'", value.replace('\'', r"'\''"))
|
format!("'{}'", value.replace('\'', r"'\''"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rules command for the given user: `setpriv` drops to the build user
|
/// The rules command for the given build identity. Under the mapped
|
||||||
/// without touching the environment (a login shell would reset it), so the
|
/// namespace the command is flagged for the unshare driver, which pivots
|
||||||
/// dpkg architecture variables and DEB_BUILD_OPTIONS survive the
|
/// into the chroot and creates the nested namespace: the process simply
|
||||||
/// transition; without a user the rules run as the context's own.
|
/// IS the build identity, no setpriv needed. The fallback `setpriv`
|
||||||
|
/// drops to the build user without touching the environment (a login
|
||||||
|
/// shell would reset it), so the dpkg architecture variables and
|
||||||
|
/// DEB_BUILD_OPTIONS survive the transition. Without an identity the
|
||||||
|
/// rules run as the context's own.
|
||||||
fn rules_command<'a>(
|
fn rules_command<'a>(
|
||||||
ctx: &'a Arc<Context>,
|
ctx: &'a Arc<Context>,
|
||||||
user: Option<&(String, String)>,
|
identity: Option<&RulesIdentity>,
|
||||||
program: &str,
|
program: &str,
|
||||||
) -> ContextCommand<'a> {
|
) -> ContextCommand<'a> {
|
||||||
match user {
|
match identity {
|
||||||
Some((uid, gid)) => {
|
Some(RulesIdentity::MappedNamespace) => {
|
||||||
|
let mut cmd = ctx.command(program);
|
||||||
|
cmd.env(crate::context::MAPPED_IDENTITY_ENV, "1");
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
Some(RulesIdentity::Setpriv(uid, gid)) => {
|
||||||
let mut cmd = ctx.command("setpriv");
|
let mut cmd = ctx.command("setpriv");
|
||||||
cmd.arg(format!("--reuid={uid}"))
|
cmd.arg(format!("--reuid={uid}"))
|
||||||
.arg(format!("--regid={gid}"))
|
.arg(format!("--regid={gid}"))
|
||||||
@@ -713,24 +949,47 @@ pub async fn build(
|
|||||||
// LTO/make -j process flood), and a rules chown that fails for the
|
// LTO/make -j process flood), and a rules chown that fails for the
|
||||||
// build user is cosmetic — the .deb is assembled in the binary step's
|
// build user is cosmetic — the .deb is assembled in the binary step's
|
||||||
// own fakeroot session, where files unknown to its database are
|
// own fakeroot session, where files unknown to its database are
|
||||||
// recorded as root-owned anyway. Handing the tree over is a selective,
|
// recorded as root-owned anyway. The mapped-namespace identity owns
|
||||||
// parallel chown (through the overlay staging each chown copy-ups the
|
// the tree by construction (no chown at all); the setpriv fallback
|
||||||
// file it touches), and it is a phase of its own in the status bar and
|
// hands it over with a selective, parallel chown, and it is a phase
|
||||||
// the profile.
|
// of its own in the status bar and the profile.
|
||||||
if build_user.is_some() {
|
if build_user.is_some() {
|
||||||
enter_phase(view, Phase::PreparingBuildUser, profiler);
|
enter_phase(view, Phase::PreparingBuildUser, profiler);
|
||||||
}
|
}
|
||||||
|
let ccache_host_dir = ccache.then(|| {
|
||||||
|
session::ccache_dir(&session::SessionIdentity {
|
||||||
|
package: package.to_string(),
|
||||||
|
series: series.to_string(),
|
||||||
|
arch: arch.to_string(),
|
||||||
|
cross,
|
||||||
|
})
|
||||||
|
});
|
||||||
let rule_ids = match build_user {
|
let rule_ids = match build_user {
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
let workroot = Path::new(package_dir_str)
|
let workroot = Path::new(package_dir_str)
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or("cannot find the staged tree's parent directory")?
|
.ok_or("cannot find the staged tree's parent directory")?
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
let ccache_dir = env.get("CCACHE_DIR").cloned();
|
Some(ensure_build_user(
|
||||||
Some(ensure_build_user(&ctx, &workroot, ccache_dir.as_deref())?)
|
&ctx,
|
||||||
|
&workroot,
|
||||||
|
session,
|
||||||
|
build_resume,
|
||||||
|
env.get("CCACHE_DIR").map(String::as_str),
|
||||||
|
ccache_host_dir.as_deref(),
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
if matches!(rule_ids, Some(RulesIdentity::MappedNamespace)) {
|
||||||
|
// fakeroot's chown wrappers attempt the real chown and swallow only
|
||||||
|
// EPERM; as the mapped identity, targets like root:root resolve to
|
||||||
|
// nothing in the nested namespace and fail with EINVAL, which
|
||||||
|
// propagates (dh_fixperms chowns to root:root). This env is
|
||||||
|
// fakeroot's own knob to skip the real attempt — the fake
|
||||||
|
// ownership is still recorded, which is all the binary step needs.
|
||||||
|
env.insert("FAKEROOTDONTTRYCHOWN".to_string(), "1".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Run the build step
|
// Run the build step
|
||||||
log::debug!("Building (debian/rules build) package...");
|
log::debug!("Building (debian/rules build) package...");
|
||||||
|
|||||||
+14
-2
@@ -46,8 +46,10 @@ pub enum Phase {
|
|||||||
/// Drop the resumed tree's input-untracked stamp caches (quirk:
|
/// Drop the resumed tree's input-untracked stamp caches (quirk:
|
||||||
/// `resume_clear`)
|
/// `resume_clear`)
|
||||||
ClearingResumeCaches,
|
ClearingResumeCaches,
|
||||||
/// Create the unprivileged build user and hand the staged tree to
|
/// Establish the unprivileged build identity for the rules steps
|
||||||
/// it (rootless builds)
|
/// (rootless builds): a nested user namespace mapping the chroot's
|
||||||
|
/// root ownership onto the identity — the tree needs no chown — or,
|
||||||
|
/// when unavailable, a build user with a chown tree handover
|
||||||
PreparingBuildUser,
|
PreparingBuildUser,
|
||||||
/// debian/rules build
|
/// debian/rules build
|
||||||
Building,
|
Building,
|
||||||
@@ -642,6 +644,16 @@ async fn build_binary_package_impl(
|
|||||||
log::info!(
|
log::info!(
|
||||||
"Reusing the build artifacts of the previous attempt (incremental build)"
|
"Reusing the build artifacts of the previous attempt (incremental build)"
|
||||||
);
|
);
|
||||||
|
// Before the overlay mounts (see the function's doc): a
|
||||||
|
// setpriv-era tree handed over here lets the mapped
|
||||||
|
// build identity run the rules; a failed handover makes
|
||||||
|
// the identity setup fall back to the chown handover.
|
||||||
|
if let Err(e) = local::migrate_upper_ownership(s) {
|
||||||
|
log::warn!(
|
||||||
|
"cannot hand the staged tree to the invoking user \
|
||||||
|
({e}); falling back to the chown handover"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let staged_path = if let Some(s) = &session {
|
let staged_path = if let Some(s) = &session {
|
||||||
|
|||||||
+5
-3
@@ -2136,9 +2136,11 @@ pub(crate) fn plan_upper_sync(
|
|||||||
///
|
///
|
||||||
/// Removals go through the privileged path: the upperdir's entries were
|
/// Removals go through the privileged path: the upperdir's entries were
|
||||||
/// copy-up'd by whoever wrote them during the build — in a rootless build
|
/// copy-up'd by whoever wrote them during the build — in a rootless build
|
||||||
/// the unprivileged build user (a subuid of the invoking user host-side) —
|
/// the unprivileged build identity, whose files are the invoking user's
|
||||||
/// so a plain removal by the invoking user fails with EPERM exactly when a
|
/// own host-side under the mapped namespace (a subuid of theirs with the
|
||||||
/// host-modified file's stale upper copy must go.
|
/// setpriv fallback, or of earlier builds) — so a plain removal by the
|
||||||
|
/// invoking user fails with EPERM exactly when a host-modified file's
|
||||||
|
/// stale upper copy must go.
|
||||||
pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result<usize> {
|
pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result<usize> {
|
||||||
let mut removed_prefixes: Vec<String> = Vec::new();
|
let mut removed_prefixes: Vec<String> = Vec::new();
|
||||||
let mut removed = 0;
|
let mut removed = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user