use std::{
fs,
io::{self, BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
};
use anyhow::{Context, bail, ensure};
pub(crate) fn read_text_file(
rootfs_img: &Path,
guest_path: &str,
) -> anyhow::Result<Option<String>> {
ensure!(
guest_path.starts_with('/'),
"guest path must be absolute: `{guest_path}`"
);
let output = Command::new("debugfs")
.arg("-R")
.arg(format!("cat {guest_path}"))
.arg(rootfs_img)
.output()
.with_context(|| format!("failed to spawn debugfs for {}", rootfs_img.display()))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
bail!(
"failed to read {guest_path} from {}: {}",
rootfs_img.display(),
stderr.trim()
);
}
if output.stdout.is_empty() && stderr.contains("File not found") {
return Ok(None);
}
String::from_utf8(output.stdout)
.map(Some)
.with_context(|| format!("{}:{guest_path} is not valid UTF-8", rootfs_img.display()))
}
pub(crate) fn replace_file(
rootfs_img: &Path,
guest_path: &str,
source_path: &Path,
) -> anyhow::Result<()> {
ensure!(
guest_path.starts_with('/'),
"guest path must be absolute: `{guest_path}`"
);
let commands = vec![
format!("rm {guest_path}"),
format!("write {} {guest_path}", source_path.display()),
];
#[cfg(unix)]
let commands = {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(source_path)
.with_context(|| format!("failed to stat {}", source_path.display()))?
.permissions()
.mode();
let mut commands = commands;
commands.push(format!("sif {guest_path} mode 0{mode:o}"));
commands
};
run_debugfs_script(
rootfs_img,
&commands,
&format!(
"failed to replace {guest_path} in {} with {}",
rootfs_img.display(),
source_path.display()
),
)
}
pub(crate) fn extract_rootfs(rootfs_img: &Path, output_dir: &Path) -> anyhow::Result<()> {
#[cfg(unix)]
let fakeroot_program = current_process_requires_fakeroot().then_some(Path::new("fakeroot"));
#[cfg(not(unix))]
let fakeroot_program = None;
RootfsExtraction {
rootfs_img,
output_dir,
debugfs_program: Path::new("debugfs"),
fakeroot_program,
}
.run()?;
relativize_absolute_symlinks(output_dir)
}
struct RootfsExtraction<'a> {
rootfs_img: &'a Path,
output_dir: &'a Path,
debugfs_program: &'a Path,
fakeroot_program: Option<&'a Path>,
}
impl RootfsExtraction<'_> {
fn run(&self) -> anyhow::Result<()> {
let mut command = self.command();
let rendered_command = format!("{command:?}");
let output = command.output().with_context(|| {
if let Some(fakeroot) = self.fakeroot_program {
format!(
"failed to spawn fakeroot `{}`; rootfs extraction without full host ownership \
privileges requires fakeroot",
fakeroot.display()
)
} else {
format!("failed to spawn debugfs for {}", self.rootfs_img.display())
}
})?;
if output.status.success() {
return Ok(());
}
eprintln!("rootfs extraction command failed: {rendered_command}");
io::stdout()
.write_all(&output.stdout)
.context("failed to replay rootfs extraction stdout")?;
io::stderr()
.write_all(&output.stderr)
.context("failed to replay rootfs extraction stderr")?;
bail!(
"failed to extract {} into {}: command exited with status {}",
self.rootfs_img.display(),
self.output_dir.display(),
output.status
);
}
fn command(&self) -> Command {
let mut command = if let Some(fakeroot) = self.fakeroot_program {
let mut command = Command::new(fakeroot);
command.arg("--").arg(self.debugfs_program);
command
} else {
Command::new(self.debugfs_program)
};
command
.arg("-R")
.arg(format!("rdump / {}", self.output_dir.display()))
.arg(self.rootfs_img);
command
}
}
#[cfg(unix)]
fn effective_uid() -> libc::uid_t {
unsafe { libc::geteuid() }
}
#[cfg(unix)]
fn current_process_requires_fakeroot() -> bool {
#[cfg(target_os = "linux")]
{
requires_fakeroot(
effective_uid(),
linux_id_map_is_full_identity("/proc/self/uid_map"),
linux_id_map_is_full_identity("/proc/self/gid_map"),
linux_has_effective_cap_chown(),
)
}
#[cfg(not(target_os = "linux"))]
{
effective_uid() != 0
}
}
#[cfg(target_os = "linux")]
fn requires_fakeroot(
effective_uid: libc::uid_t,
full_uid_map: bool,
full_gid_map: bool,
has_effective_cap_chown: bool,
) -> bool {
effective_uid != 0 || !full_uid_map || !full_gid_map || !has_effective_cap_chown
}
#[cfg(target_os = "linux")]
fn linux_id_map_is_full_identity(path: &str) -> bool {
fs::read_to_string(path)
.ok()
.is_some_and(|contents| id_map_is_full_identity(&contents))
}
#[cfg(target_os = "linux")]
fn id_map_is_full_identity(contents: &str) -> bool {
let mut lines = contents.lines().filter(|line| !line.trim().is_empty());
let Some(line) = lines.next() else {
return false;
};
if lines.next().is_some() {
return false;
}
let fields = line
.split_ascii_whitespace()
.map(str::parse::<u64>)
.collect::<Result<Vec<_>, _>>();
matches!(fields.as_deref(), Ok([0, 0, 4_294_967_295]))
}
#[cfg(target_os = "linux")]
fn linux_has_effective_cap_chown() -> bool {
fs::read_to_string("/proc/self/status")
.ok()
.is_some_and(|status| status_has_effective_cap_chown(&status))
}
#[cfg(target_os = "linux")]
fn status_has_effective_cap_chown(status: &str) -> bool {
status
.lines()
.find_map(|line| line.strip_prefix("CapEff:"))
.and_then(|value| u64::from_str_radix(value.trim(), 16).ok())
.is_some_and(|capabilities| capabilities & 1 != 0)
}
fn relativize_absolute_symlinks(root: &Path) -> anyhow::Result<()> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in
fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("failed to inspect {}", path.display()))?;
if file_type.is_dir() {
stack.push(path);
continue;
}
if !file_type.is_symlink() {
continue;
}
let target = fs::read_link(&path)
.with_context(|| format!("failed to read symlink {}", path.display()))?;
let Ok(guest_target) = target.strip_prefix("/") else {
continue;
};
let in_root = root.join(guest_target);
let (Some(link_dir), true) = (path.parent(), in_root.exists()) else {
continue;
};
let relative = relative_symlink_target(link_dir, &in_root);
fs::remove_file(&path)
.with_context(|| format!("failed to replace symlink {}", path.display()))?;
std::os::unix::fs::symlink(&relative, &path).with_context(|| {
format!(
"failed to relink {} -> {}",
path.display(),
relative.display()
)
})?;
}
}
Ok(())
}
fn relative_symlink_target(from_dir: &Path, to: &Path) -> PathBuf {
let from: Vec<_> = from_dir.components().collect();
let to: Vec<_> = to.components().collect();
let shared = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
let mut relative = PathBuf::new();
for _ in shared..from.len() {
relative.push("..");
}
for component in &to[shared..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
relative.push(".");
}
relative
}
pub(crate) fn inject_overlay(rootfs_img: &Path, overlay_dir: &Path) -> anyhow::Result<()> {
ensure!(
overlay_has_entries(overlay_dir)?,
"overlay injection source is empty: {}",
overlay_dir.display()
);
let mut commands = Vec::new();
collect_overlay_debugfs_commands(overlay_dir, Path::new(""), &mut commands)?;
run_debugfs_script(
rootfs_img,
&commands,
&format!(
"failed to inject overlay {} into {}",
overlay_dir.display(),
rootfs_img.display()
),
)
}
fn overlay_has_entries(overlay_dir: &Path) -> anyhow::Result<bool> {
Ok(fs::read_dir(overlay_dir)
.with_context(|| format!("failed to read {}", overlay_dir.display()))?
.next()
.is_some())
}
fn collect_overlay_debugfs_commands(
overlay_dir: &Path,
relative_dir: &Path,
commands: &mut Vec<String>,
) -> anyhow::Result<()> {
let current_dir = if relative_dir.as_os_str().is_empty() {
overlay_dir.to_path_buf()
} else {
overlay_dir.join(relative_dir)
};
let mut entries = fs::read_dir(¤t_dir)
.with_context(|| format!("failed to read {}", current_dir.display()))?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("failed to read {}", current_dir.display()))?;
entries.sort_by_key(|left| left.file_name());
for entry in &entries {
let file_name = PathBuf::from(entry.file_name());
let relative_path = relative_dir.join(&file_name);
let file_type = entry
.file_type()
.with_context(|| format!("failed to inspect {}", entry.path().display()))?;
if file_type.is_dir() {
commands.push(format!("mkdir /{}", relative_path.display()));
collect_overlay_debugfs_commands(overlay_dir, &relative_path, commands)?;
continue;
}
if file_type.is_symlink() {
continue;
}
ensure!(
file_type.is_file(),
"unsupported overlay entry `{}`; only regular files, directories, and symlinks are \
supported",
entry.path().display()
);
commands.push(format!("rm /{}", relative_path.display()));
commands.push(format!(
"write {} /{}",
entry.path().display(),
relative_path.display()
));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(entry.path())
.with_context(|| format!("failed to stat {}", entry.path().display()))?;
commands.push(format!(
"sif /{} mode 0{:o}",
relative_path.display(),
metadata.permissions().mode()
));
}
}
for entry in &entries {
let file_name = PathBuf::from(entry.file_name());
let relative_path = relative_dir.join(&file_name);
let file_type = entry
.file_type()
.with_context(|| format!("failed to inspect {}", entry.path().display()))?;
if file_type.is_symlink() {
let host_target = fs::read_link(entry.path())
.with_context(|| format!("failed to read symlink {}", entry.path().display()))?;
let guest_filespec = if host_target.is_relative() {
let guest_dir = Path::new("/").join(relative_dir);
guest_dir.join(&host_target)
} else {
host_target.clone()
};
commands.push(format!("rm /{}", relative_path.display()));
commands.push(format!(
"symlink /{} {}",
relative_path.display(),
guest_filespec.display()
));
}
}
Ok(())
}
fn run_debugfs_script(
rootfs_img: &Path,
commands: &[String],
context_message: &str,
) -> anyhow::Result<()> {
eprintln!("debugfs -w {}", rootfs_img.display());
let mut child = Command::new("debugfs")
.arg("-w")
.arg(rootfs_img)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn debugfs for {}", rootfs_img.display()))?;
let stderr_handle = child
.stderr
.take()
.context("failed to open debugfs stderr")?;
let filter_handle = thread::spawn(move || {
let reader = BufReader::new(stderr_handle);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.contains("File exists") || line.contains("already exists") {
continue;
}
eprintln!("{line}");
}
});
{
let mut stdin = child.stdin.take().context("failed to open debugfs stdin")?;
for command in commands {
writeln!(stdin, "{command}").context("failed to write debugfs command")?;
}
writeln!(stdin, "quit").context("failed to finalize debugfs script")?;
}
let status = child.wait().context("failed to wait for debugfs")?;
let _ = filter_handle.join();
if status.success() {
Ok(())
} else {
bail!("{context_message}: debugfs exited with status {status}");
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use tempfile::tempdir;
use super::*;
#[cfg(unix)]
#[test]
fn overlay_debugfs_commands_include_paths_and_modes() {
let root = tempdir().unwrap();
let overlay_dir = root.path().join("overlay");
fs::create_dir_all(overlay_dir.join("usr/bin")).unwrap();
let binary = overlay_dir.join("usr/bin/test-bin");
fs::write(&binary, b"bin").unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap();
}
let mut commands = Vec::new();
collect_overlay_debugfs_commands(&overlay_dir, Path::new(""), &mut commands).unwrap();
assert_eq!(commands[0], "mkdir /usr");
assert!(commands.contains(&"mkdir /usr/bin".to_string()));
assert!(commands.contains(&format!("write {} /usr/bin/test-bin", binary.display())));
assert!(commands.contains(&"sif /usr/bin/test-bin mode 0100755".to_string()));
}
#[cfg(unix)]
#[test]
fn symlinks_are_emitted_after_regular_files() {
use std::os::unix;
let root = tempdir().unwrap();
let overlay_dir = root.path().join("overlay");
let lib = overlay_dir.join("usr/lib");
fs::create_dir_all(&lib).unwrap();
fs::write(lib.join("libfoo.so.1.2.0"), b"elf").unwrap();
unix::fs::symlink("libfoo.so.1.2.0", lib.join("libfoo.so.1")).unwrap();
unix::fs::symlink("libfoo.so.1", lib.join("libfoo.so")).unwrap();
let mut commands = Vec::new();
collect_overlay_debugfs_commands(&overlay_dir, Path::new(""), &mut commands).unwrap();
let write_pos = commands
.iter()
.position(|c| c.contains("libfoo.so.1.2.0") && c.starts_with("write "))
.unwrap();
let sym1_pos = commands
.iter()
.position(|c| c == "symlink /usr/lib/libfoo.so.1 /usr/lib/libfoo.so.1.2.0")
.unwrap();
let sym0_pos = commands
.iter()
.position(|c| c == "symlink /usr/lib/libfoo.so /usr/lib/libfoo.so.1")
.unwrap();
assert!(
sym1_pos > write_pos,
"symlink must be second pass, after its target"
);
assert!(
sym0_pos > write_pos,
"symlink must be second pass, after its target"
);
}
#[cfg(unix)]
#[test]
fn non_root_extraction_starts_debugfs_inside_fakeroot() {
let root = tempdir().unwrap();
let fakeroot = root.path().join("fakeroot");
let debugfs = root.path().join("debugfs");
let marker = root.path().join("debugfs-ran-inside-fakeroot");
write_executable(
&fakeroot,
"#!/bin/sh\ntest \"$1\" = \"--\" || exit 91\nshift\nexport \
AXBUILD_TEST_FAKEROOT=1\nexec \"$@\"\n",
);
write_executable(
&debugfs,
&format!(
"#!/bin/sh\ntest \"${{AXBUILD_TEST_FAKEROOT:-}}\" = \"1\" || exit 92\ntouch '{}'\n",
marker.display()
),
);
let output_dir = root.path().join("staging");
fs::create_dir(&output_dir).unwrap();
RootfsExtraction {
rootfs_img: Path::new("rootfs.img"),
output_dir: &output_dir,
debugfs_program: &debugfs,
fakeroot_program: Some(&fakeroot),
}
.run()
.unwrap();
assert!(marker.exists());
}
#[cfg(unix)]
#[test]
fn missing_fakeroot_fails_before_debugfs_starts() {
let root = tempdir().unwrap();
let debugfs = root.path().join("debugfs");
let marker = root.path().join("debugfs-started");
write_executable(
&debugfs,
&format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
);
let output_dir = root.path().join("staging");
fs::create_dir(&output_dir).unwrap();
let error = RootfsExtraction {
rootfs_img: Path::new("rootfs.img"),
output_dir: &output_dir,
debugfs_program: &debugfs,
fakeroot_program: Some(&root.path().join("missing-fakeroot")),
}
.run()
.unwrap_err();
assert!(
error.to_string().contains("failed to spawn fakeroot"),
"unexpected error: {error:#}"
);
assert!(!marker.exists(), "debugfs must not run without fakeroot");
}
#[cfg(target_os = "linux")]
#[test]
fn direct_extraction_requires_full_host_ownership_privileges() {
assert!(!requires_fakeroot(0, true, true, true));
assert!(requires_fakeroot(1, true, true, true));
assert!(requires_fakeroot(0, false, true, true));
assert!(requires_fakeroot(0, true, false, true));
assert!(requires_fakeroot(0, true, true, false));
}
#[cfg(target_os = "linux")]
#[test]
fn full_identity_map_rejects_partial_or_split_user_namespaces() {
assert!(id_map_is_full_identity("0 0 4294967295\n"));
assert!(!id_map_is_full_identity("0 1000 1\n"));
assert!(!id_map_is_full_identity("0 1000 1\n1 100000 65535\n"));
}
#[cfg(target_os = "linux")]
#[test]
fn cap_chown_parser_requires_effective_capability_bit() {
assert!(status_has_effective_cap_chown(
"Name:\ttg-xtask\nCapEff:\t0000000000000001\n"
));
assert!(!status_has_effective_cap_chown(
"Name:\ttg-xtask\nCapEff:\t0000000000000000\n"
));
assert!(!status_has_effective_cap_chown(
"Name:\ttg-xtask\nCapEff:\tinvalid\n"
));
}
#[cfg(unix)]
fn write_executable(path: &Path, contents: &str) {
use std::os::unix::fs::PermissionsExt;
fs::write(path, contents).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
}