diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 4bf3b60..428f1d3 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -815,8 +815,10 @@ impl EphemeralContextGuard { view: &dyn BuildView, ccache_dir: Option<&Path>, ) -> Result, Box> { - if !chroot_path.join("dev/null").exists() { - // Create device nodes in the chroot + if !Self::device_nodes_ready(chroot_path) { + // 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..."); enter_phase(view, Phase::FinalizingChroot); Self::create_device_nodes(chroot_path, ctx.clone())?; @@ -1059,61 +1061,78 @@ impl EphemeralContextGuard { 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( chroot_path: &Path, ctx: Arc, ) -> Result<(), Box> { - let dev_null_path = chroot_path.join("dev/null"); - let dev_zero_path = chroot_path.join("dev/zero"); - // Ensure /dev directory exists 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 let is_root = crate::utils::root::is_root()?; - // Create new device nodes using mknod (with sudo if not root) - let mut cmd_null = ctx.command(if is_root { "mknod" } else { "sudo" }); - 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()?; + for (name, major, minor) in Self::DEVICE_NODES { + let path = chroot_path.join("dev").join(name); - let mut cmd_zero = ctx.command(if is_root { "mknod" } else { "sudo" }); - if !is_root { - cmd_zero.arg("mknod"); - } - let status_zero = cmd_zero - .arg("-m") - .arg("666") - .arg(dev_zero_path.to_string_lossy().to_string()) - .arg("c") - .arg("1") - .arg("5") - .status()?; + // Clear whatever is there: the empty regular file the + // extraction leaves behind, or a node from an earlier run. + let _ = ctx + .command("rm") + .arg("-f") + .arg(path.to_string_lossy().to_string()) + .status(); - if !status_null.success() || !status_zero.success() { - return Err("Failed to create device nodes".into()); + // Create the node using mknod (with sudo if not root) + 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(()) @@ -1383,6 +1402,44 @@ mod chroot_cleanup_tests { 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 /// when non-root without working non-interactive sudo, since removal then /// legitimately fails (and is only logged).