deb: create the chroot's device nodes as char devices
The buildd tarballs carry their /dev entries as char-device members, but the tar crate cannot create special files: unrecognized entry types are written out as regular ones, so every device entry in the chroot extracts to an empty regular file. The old readiness guard only checked that /dev/null existed, which a regular file satisfies, so the rm + mknod repair never ran. Redirects into a regular /dev/null still succeed, so the build proceeds until something reads the node back. The first reader in practice is dh_strip's dbgsym path, where dpkg-gencontrol gets -T/dev/null (dbgsym packages have no substvars file) and aborts on "bad line in substvars file /dev/null at line 1". Packages without debug symbols never reach that call, which is why earlier builds looked fine. The guard now requires every node to be a char device, and the node set covers all entries the tarballs ship (console, full, null, ptmx, random, tty, urandom, zero), not just null and zero: a regular-file /dev/urandom or /dev/console is a silent hazard for anything reading it later in the build. Regression tests pin both shapes: the regular files extraction leaves behind must not count as ready, and real char devices (via symlinks to the host's nodes, so the test runs unprivileged) must.
This commit is contained in:
+102
-45
@@ -815,8 +815,10 @@ impl EphemeralContextGuard {
|
|||||||
view: &dyn BuildView,
|
view: &dyn BuildView,
|
||||||
ccache_dir: Option<&Path>,
|
ccache_dir: Option<&Path>,
|
||||||
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
||||||
if !chroot_path.join("dev/null").exists() {
|
if !Self::device_nodes_ready(chroot_path) {
|
||||||
// Create device nodes in the chroot
|
// The tar crate materializes the tarball's device entries as
|
||||||
|
// empty regular files (see [Self::DEVICE_NODES]), so readiness
|
||||||
|
// is keyed on the nodes being char devices, not on presence.
|
||||||
log::debug!("Creating device nodes in chroot...");
|
log::debug!("Creating device nodes in chroot...");
|
||||||
enter_phase(view, Phase::FinalizingChroot);
|
enter_phase(view, Phase::FinalizingChroot);
|
||||||
Self::create_device_nodes(chroot_path, ctx.clone())?;
|
Self::create_device_nodes(chroot_path, ctx.clone())?;
|
||||||
@@ -1059,61 +1061,78 @@ impl EphemeralContextGuard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The device nodes a buildd chroot needs, as (name, major, minor).
|
||||||
|
///
|
||||||
|
/// The chroot tarballs carry these as char-device entries, but the
|
||||||
|
/// tar crate cannot create special files: it treats entry types it
|
||||||
|
/// does not recognize as regular ones, so extraction leaves *empty
|
||||||
|
/// regular files* at these paths. Anything redirecting into them
|
||||||
|
/// then writes real data, and a reader gets garbage (a `/dev/null`
|
||||||
|
/// passed to `dpkg-gencontrol -T` aborts the build with "bad line
|
||||||
|
/// in substvars file"). Hence the nodes are (re)created with mknod,
|
||||||
|
/// gated on being char devices rather than merely existing.
|
||||||
|
const DEVICE_NODES: &[(&str, u32, u32)] = &[
|
||||||
|
("console", 5, 1),
|
||||||
|
("full", 1, 7),
|
||||||
|
("null", 1, 3),
|
||||||
|
("ptmx", 5, 2),
|
||||||
|
("random", 1, 8),
|
||||||
|
("tty", 5, 0),
|
||||||
|
("urandom", 1, 9),
|
||||||
|
("zero", 1, 5),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether every node in [`Self::DEVICE_NODES`] exists as a char
|
||||||
|
/// device in the chroot.
|
||||||
|
fn device_nodes_ready(chroot_path: &Path) -> bool {
|
||||||
|
use std::os::unix::fs::FileTypeExt;
|
||||||
|
|
||||||
|
Self::DEVICE_NODES.iter().all(|(name, _, _)| {
|
||||||
|
chroot_path
|
||||||
|
.join("dev")
|
||||||
|
.join(name)
|
||||||
|
.metadata()
|
||||||
|
.is_ok_and(|meta| meta.file_type().is_char_device())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn create_device_nodes(
|
fn create_device_nodes(
|
||||||
chroot_path: &Path,
|
chroot_path: &Path,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let dev_null_path = chroot_path.join("dev/null");
|
|
||||||
let dev_zero_path = chroot_path.join("dev/zero");
|
|
||||||
|
|
||||||
// Ensure /dev directory exists
|
// Ensure /dev directory exists
|
||||||
fs::create_dir_all(chroot_path.join("dev"))?;
|
fs::create_dir_all(chroot_path.join("dev"))?;
|
||||||
|
|
||||||
// Remove existing device nodes if they exist
|
|
||||||
let _ = ctx
|
|
||||||
.command("rm")
|
|
||||||
.arg("-f")
|
|
||||||
.arg(dev_null_path.to_string_lossy().to_string())
|
|
||||||
.status();
|
|
||||||
|
|
||||||
let _ = ctx
|
|
||||||
.command("rm")
|
|
||||||
.arg("-f")
|
|
||||||
.arg(dev_zero_path.to_string_lossy().to_string())
|
|
||||||
.status();
|
|
||||||
|
|
||||||
// Check if we're running as root
|
// Check if we're running as root
|
||||||
let is_root = crate::utils::root::is_root()?;
|
let is_root = crate::utils::root::is_root()?;
|
||||||
|
|
||||||
// Create new device nodes using mknod (with sudo if not root)
|
for (name, major, minor) in Self::DEVICE_NODES {
|
||||||
let mut cmd_null = ctx.command(if is_root { "mknod" } else { "sudo" });
|
let path = chroot_path.join("dev").join(name);
|
||||||
if !is_root {
|
|
||||||
cmd_null.arg("mknod");
|
|
||||||
}
|
|
||||||
let status_null = cmd_null
|
|
||||||
.arg("-m")
|
|
||||||
.arg("666")
|
|
||||||
.arg(dev_null_path.to_string_lossy().to_string())
|
|
||||||
.arg("c")
|
|
||||||
.arg("1")
|
|
||||||
.arg("3")
|
|
||||||
.status()?;
|
|
||||||
|
|
||||||
let mut cmd_zero = ctx.command(if is_root { "mknod" } else { "sudo" });
|
// Clear whatever is there: the empty regular file the
|
||||||
if !is_root {
|
// extraction leaves behind, or a node from an earlier run.
|
||||||
cmd_zero.arg("mknod");
|
let _ = ctx
|
||||||
}
|
.command("rm")
|
||||||
let status_zero = cmd_zero
|
.arg("-f")
|
||||||
.arg("-m")
|
.arg(path.to_string_lossy().to_string())
|
||||||
.arg("666")
|
.status();
|
||||||
.arg(dev_zero_path.to_string_lossy().to_string())
|
|
||||||
.arg("c")
|
|
||||||
.arg("1")
|
|
||||||
.arg("5")
|
|
||||||
.status()?;
|
|
||||||
|
|
||||||
if !status_null.success() || !status_zero.success() {
|
// Create the node using mknod (with sudo if not root)
|
||||||
return Err("Failed to create device nodes".into());
|
let mut cmd = ctx.command(if is_root { "mknod" } else { "sudo" });
|
||||||
|
if !is_root {
|
||||||
|
cmd.arg("mknod");
|
||||||
|
}
|
||||||
|
let status = cmd
|
||||||
|
.arg("-m")
|
||||||
|
.arg("666")
|
||||||
|
.arg(path.to_string_lossy().to_string())
|
||||||
|
.arg("c")
|
||||||
|
.arg(major.to_string())
|
||||||
|
.arg(minor.to_string())
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("Failed to create device node /dev/{name}").into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1383,6 +1402,44 @@ mod chroot_cleanup_tests {
|
|||||||
assert!(!missing.exists());
|
assert!(!missing.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the tar crate leaves after extracting the chroot tarball:
|
||||||
|
/// every device entry materializes as an empty *regular* file. The
|
||||||
|
/// readiness guard must reject that shape, or `/dev/null` stays a
|
||||||
|
/// regular file and a reader of a redirected write sees garbage (the
|
||||||
|
/// `dpkg-gencontrol -T/dev/null` substvars abort).
|
||||||
|
#[test]
|
||||||
|
fn device_nodes_reject_regular_files_left_by_extraction() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let chroot = dir.path().join("chroot");
|
||||||
|
std::fs::create_dir_all(chroot.join("dev")).unwrap();
|
||||||
|
for (name, _, _) in EphemeralContextGuard::DEVICE_NODES {
|
||||||
|
std::fs::write(chroot.join("dev").join(name), b"").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!EphemeralContextGuard::device_nodes_ready(&chroot),
|
||||||
|
"regular files must not count as ready device nodes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ready case without root: a symlink to the host's real node,
|
||||||
|
/// whose metadata resolves to the char device.
|
||||||
|
#[test]
|
||||||
|
fn device_nodes_accept_real_char_devices() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let chroot = dir.path().join("chroot");
|
||||||
|
std::fs::create_dir_all(chroot.join("dev")).unwrap();
|
||||||
|
for (name, _, _) in EphemeralContextGuard::DEVICE_NODES {
|
||||||
|
std::os::unix::fs::symlink(
|
||||||
|
format!("/dev/{name}"),
|
||||||
|
chroot.join("dev").join(name),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(EphemeralContextGuard::device_nodes_ready(&chroot));
|
||||||
|
}
|
||||||
|
|
||||||
/// A real directory with no mounts under it is simply removed. Skipped
|
/// A real directory with no mounts under it is simply removed. Skipped
|
||||||
/// when non-root without working non-interactive sudo, since removal then
|
/// when non-root without working non-interactive sudo, since removal then
|
||||||
/// legitimately fails (and is only logged).
|
/// legitimately fails (and is only logged).
|
||||||
|
|||||||
Reference in New Issue
Block a user