use crate::images::OverrideStat;
use crate::util;
use boxlite_shared::{BoxliteError, BoxliteResult};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
use super::constants::ext4::{
BLOCK_SIZE, DEFAULT_DIR_SIZE_BYTES, INODE_SIZE, JOURNAL_OVERHEAD_BYTES, MIN_DISK_SIZE_BYTES,
SIZE_MULTIPLIER_DEN, SIZE_MULTIPLIER_NUM,
};
use super::{Disk, DiskFormat};
fn get_mke2fs_path() -> PathBuf {
util::find_binary("mke2fs").expect("mke2fs binary not found")
}
fn get_debugfs_path() -> PathBuf {
util::find_binary("debugfs").expect("debugfs binary not found")
}
fn calculate_dir_size(dir: &Path) -> BoxliteResult<u64> {
let mut total_blocks = 0u64;
let mut entry_count = 0u64;
for entry in WalkDir::new(dir).follow_links(false) {
let entry = entry.map_err(|e| {
BoxliteError::Storage(format!("Failed to walk directory {}: {}", dir.display(), e))
})?;
entry_count += 1;
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
let file_blocks = metadata.len().div_ceil(BLOCK_SIZE);
total_blocks += file_blocks.max(1);
} else if metadata.is_dir() {
total_blocks += 1;
}
}
}
let content_size = total_blocks * BLOCK_SIZE;
let inode_size = entry_count * INODE_SIZE;
Ok(content_size + inode_size)
}
fn calculate_disk_size(source: &Path, reserve_bytes: u64) -> u64 {
disk_size_with_overhead(
calculate_dir_size(source).unwrap_or(DEFAULT_DIR_SIZE_BYTES),
reserve_bytes,
)
}
fn disk_size_with_overhead(dir_size: u64, reserve_bytes: u64) -> u64 {
let size_with_overhead = dir_size * SIZE_MULTIPLIER_NUM / SIZE_MULTIPLIER_DEN
+ JOURNAL_OVERHEAD_BYTES
+ reserve_bytes;
let final_size = size_with_overhead.max(MIN_DISK_SIZE_BYTES);
tracing::debug!(
"Calculated disk size: dir_size={}MB, reserve={}MB, with_overhead={}MB, final={}MB",
dir_size / (1024 * 1024),
reserve_bytes / (1024 * 1024),
size_with_overhead / (1024 * 1024),
final_size / (1024 * 1024)
);
final_size
}
struct WidenedPerm {
ext4_path: String,
source_path: PathBuf,
mode: u32,
}
struct InodeOwner {
ext4_path: String,
uid: u32,
gid: u32,
}
struct SourceScan {
owners: Vec<InodeOwner>,
unrecorded: usize,
total_blocks: u64,
entry_count: u64,
}
impl SourceScan {
fn dir_size(&self) -> u64 {
self.total_blocks * BLOCK_SIZE + self.entry_count * INODE_SIZE
}
fn account(&mut self, meta: &std::fs::Metadata) {
self.entry_count += 1;
if meta.is_file() {
self.total_blocks += meta.len().div_ceil(BLOCK_SIZE).max(1);
} else if meta.is_dir() {
self.total_blocks += 1;
}
}
fn record_owner(&mut self, source_root: &Path, path: &Path) -> BoxliteResult<()> {
let rel = path.strip_prefix(source_root).unwrap_or(path);
if rel.as_os_str().is_empty() {
return Ok(()); }
let (uid, gid) = match OverrideStat::read_xattr(path).map_err(|e| {
BoxliteError::Storage(format!(
"Failed to read ownership xattr on {}: {}",
path.display(),
e
))
})? {
Some(stat) => (stat.uid, stat.gid),
None => {
self.unrecorded += 1;
(0, 0)
}
};
self.owners.push(InodeOwner {
ext4_path: format!("/{}", rel.display()),
uid,
gid,
});
Ok(())
}
}
fn scan_source_tree(source: &Path, widened: &mut Vec<WidenedPerm>) -> BoxliteResult<SourceScan> {
let mut scan = SourceScan {
owners: Vec::new(),
unrecorded: 0,
total_blocks: 0,
entry_count: 0,
};
scan_dir_recursive(source, source, &mut scan, widened)?;
Ok(scan)
}
fn scan_dir_recursive(
source_root: &Path,
dir: &Path,
scan: &mut SourceScan,
widened: &mut Vec<WidenedPerm>,
) -> BoxliteResult<()> {
use std::os::unix::fs::MetadataExt;
let dir_meta = std::fs::symlink_metadata(dir)
.map_err(|e| BoxliteError::Storage(format!("Failed to stat {}: {}", dir.display(), e)))?;
let dir_mode = dir_meta.mode();
if dir_mode & 0o500 != 0o500 {
record_and_widen(source_root, dir, dir_mode, dir_mode | 0o500, widened)?;
}
scan.account(&dir_meta);
scan.record_owner(source_root, dir)?;
let entries = std::fs::read_dir(dir).map_err(|e| {
BoxliteError::Storage(format!("Failed to read dir {}: {}", dir.display(), e))
})?;
for entry in entries {
let path = entry
.map_err(|e| {
BoxliteError::Storage(format!("Failed to read entry in {}: {}", dir.display(), e))
})?
.path();
let meta = std::fs::symlink_metadata(&path).map_err(|e| {
BoxliteError::Storage(format!("Failed to stat {}: {}", path.display(), e))
})?;
let file_type = meta.file_type();
if file_type.is_dir() {
scan_dir_recursive(source_root, &path, scan, widened)?;
continue;
}
if file_type.is_file() && meta.mode() & 0o400 == 0 {
record_and_widen(
source_root,
&path,
meta.mode(),
meta.mode() | 0o400,
widened,
)?;
}
scan.account(&meta);
scan.record_owner(source_root, &path)?;
}
Ok(())
}
fn record_and_widen(
source_root: &Path,
path: &Path,
orig_mode: u32,
new_mode: u32,
widened: &mut Vec<WidenedPerm>,
) -> BoxliteResult<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(new_mode & 0o7777)).map_err(
|e| {
BoxliteError::Storage(format!(
"Failed to grant owner read on {} (mode {:04o}); is it owned by the current user? {}",
path.display(),
orig_mode & 0o7777,
e
))
},
)?;
let rel = path.strip_prefix(source_root).unwrap_or(path);
widened.push(WidenedPerm {
ext4_path: format!("/{}", rel.display()),
source_path: path.to_path_buf(),
mode: orig_mode,
});
Ok(())
}
struct SourceModeGuard {
widened: Vec<WidenedPerm>,
}
impl Drop for SourceModeGuard {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
for w in self.widened.iter().rev() {
if let Err(e) = std::fs::set_permissions(
&w.source_path,
std::fs::Permissions::from_mode(w.mode & 0o7777),
) {
tracing::warn!(
"Failed to restore source mode on {}: {}",
w.source_path.display(),
e
);
}
}
}
}
pub fn create_ext4_from_dir(
source: &Path,
output_path: &Path,
reserve_bytes: u64,
) -> BoxliteResult<Disk> {
let output_str = output_path.to_str().ok_or_else(|| {
BoxliteError::Storage(format!("Invalid output path: {}", output_path.display()))
})?;
let source_str = source.to_str().ok_or_else(|| {
BoxliteError::Storage(format!("Invalid source path: {}", source.display()))
})?;
let mut source_modes = SourceModeGuard {
widened: Vec::new(),
};
let scan = if unsafe { libc::geteuid() } != 0 {
Some(scan_source_tree(source, &mut source_modes.widened)?)
} else {
None
};
let size_bytes = match &scan {
Some(scan) => disk_size_with_overhead(scan.dir_size(), reserve_bytes),
None => calculate_disk_size(source, reserve_bytes),
};
let size_blocks = size_bytes / 4096;
let mke2fs = get_mke2fs_path();
let output = Command::new(&mke2fs)
.args([
"-t",
"ext4",
"-b",
"4096", "-d",
source_str,
"-m",
"0",
"-E",
"root_owner=0:0",
"-F", "-q", output_str,
&size_blocks.to_string(),
])
.output()
.map_err(|e| {
BoxliteError::Storage(format!(
"Failed to run mke2fs ({}): {}",
mke2fs.display(),
e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BoxliteError::Storage(format!(
"mke2fs failed with exit code {:?}: {}",
output.status.code(),
stderr
)));
}
if let Some(scan) = &scan {
normalize_inodes_with_debugfs(output_path, scan, &source_modes.widened)?;
}
let disk = Disk::new(output_path.to_path_buf(), DiskFormat::Ext4, false);
Ok(disk)
}
fn check_debugfs_output(what: &str, output: &std::process::Output) -> BoxliteResult<()> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BoxliteError::Storage(format!(
"debugfs failed (exit {:?}) while {}: {}",
output.status.code(),
what,
stderr
)));
}
let after_banner = match output.stderr.iter().position(|&b| b == b'\n') {
Some(newline) => &output.stderr[newline + 1..],
None => &output.stderr[..],
};
if !after_banner.is_empty() {
return Err(BoxliteError::Storage(format!(
"debugfs reported unexpected output while {}: {}",
what,
String::from_utf8_lossy(after_banner)
)));
}
Ok(())
}
fn quote_debugfs_path(path: &str) -> BoxliteResult<String> {
if path.contains(['"', '\n', '\r']) {
return Err(BoxliteError::Storage(format!(
"debugfs cannot safely address path {path:?}"
)));
}
Ok(format!("\"{path}\""))
}
fn normalize_inodes_with_debugfs(
image_path: &Path,
scan: &SourceScan,
widened: &[WidenedPerm],
) -> BoxliteResult<()> {
let current_uid = unsafe { libc::getuid() };
let current_gid = unsafe { libc::getgid() };
if current_uid == 0 && current_gid == 0 {
tracing::debug!("Running as root, skipping debugfs inode normalization");
return Ok(());
}
let start = std::time::Instant::now();
let owners = &scan.owners;
if owners.is_empty() && widened.is_empty() {
tracing::debug!("No inodes to normalize");
return Ok(());
}
let mut commands = String::new();
for owner in owners {
let ext4_path = quote_debugfs_path(&owner.ext4_path)?;
commands.push_str(&format!("sif {} uid {}\n", ext4_path, owner.uid));
commands.push_str(&format!("sif {} gid {}\n", ext4_path, owner.gid));
}
for w in widened {
let ext4_path = quote_debugfs_path(&w.ext4_path)?;
commands.push_str(&format!("sif {} mode 0{:o}\n", ext4_path, w.mode));
}
let debugfs = get_debugfs_path();
let mut child = Command::new(&debugfs)
.args(["-w", "-f", "-"])
.arg(image_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| BoxliteError::Storage(format!("Failed to spawn debugfs: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(commands.as_bytes()).map_err(|e| {
BoxliteError::Storage(format!("Failed to write to debugfs stdin: {}", e))
})?;
}
let output = child
.wait_with_output()
.map_err(|e| BoxliteError::Storage(format!("Failed to wait for debugfs: {}", e)))?;
let duration = start.elapsed();
check_debugfs_output(&format!("normalizing {}", image_path.display()), &output)?;
tracing::info!(
"Normalized {} inodes ({} without recorded ownership → 0:0, {} mode-restored) in {:?}",
owners.len(),
scan.unrecorded,
widened.len(),
duration
);
Ok(())
}
pub fn inject_file_into_ext4(
image_path: &Path,
host_file: &Path,
guest_path: &str,
) -> BoxliteResult<()> {
let host_file_str = host_file.to_str().ok_or_else(|| {
BoxliteError::Storage(format!("Invalid host file path: {}", host_file.display()))
})?;
let commands = build_inject_commands(host_file_str, guest_path);
let debugfs = get_debugfs_path();
let mut child = Command::new(&debugfs)
.args(["-w", "-f", "-"])
.arg(image_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
BoxliteError::Storage(format!("Failed to spawn debugfs for injection: {}", e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(commands.as_bytes()).map_err(|e| {
BoxliteError::Storage(format!("Failed to write to debugfs stdin: {}", e))
})?;
}
let output = child
.wait_with_output()
.map_err(|e| BoxliteError::Storage(format!("Failed to wait for debugfs: {}", e)))?;
check_debugfs_output(
&format!("injecting {} -> {}", host_file.display(), guest_path),
&output,
)?;
tracing::debug!(
"Injected {} into ext4 image at /{}",
host_file.display(),
guest_path
);
Ok(())
}
fn build_inject_commands(host_file_str: &str, guest_path: &str) -> String {
let mut commands = String::new();
let guest_path_obj = Path::new(guest_path);
let mut current = PathBuf::new();
if let Some(parent) = guest_path_obj.parent() {
for component in parent.components() {
current.push(component);
commands.push_str(&format!("mkdir /{}\n", current.display()));
}
}
let ext4_dest = format!("/{}", guest_path);
commands.push_str(&format!("write \"{}\" {}\n", host_file_str, ext4_dest));
commands.push_str(&format!("sif {} uid 0\n", ext4_dest));
commands.push_str(&format!("sif {} gid 0\n", ext4_dest));
commands.push_str(&format!("sif {} mode 0100555\n", ext4_dest));
let mut current = PathBuf::new();
if let Some(parent) = guest_path_obj.parent() {
for component in parent.components() {
current.push(component);
let dir_path = format!("/{}", current.display());
commands.push_str(&format!("sif {} uid 0\n", dir_path));
commands.push_str(&format!("sif {} gid 0\n", dir_path));
}
}
commands
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_debugfs_path_preserves_whitespace_and_rejects_command_delimiters() {
assert_eq!(
quote_debugfs_path("/usr/lib/launcher manifest.xml").unwrap(),
"\"/usr/lib/launcher manifest.xml\""
);
assert!(quote_debugfs_path("/tmp/quote\"file").is_err());
assert!(quote_debugfs_path("/tmp/line\nbreak").is_err());
}
#[test]
fn normalize_inodes_with_debugfs_fails_on_unresolvable_path() {
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: root skips debugfs normalization entirely");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
std::fs::create_dir_all(&src).expect("mkdir rootfs");
std::fs::write(src.join("real"), b"x").expect("write real");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0).expect("ext4 build must succeed");
let scan = SourceScan {
owners: vec![InodeOwner {
ext4_path: "/does-not-exist".to_string(),
uid: 1001,
gid: 1001,
}],
unrecorded: 0,
total_blocks: 0,
entry_count: 0,
};
let result = normalize_inodes_with_debugfs(&out, &scan, &[]);
assert!(
result.is_err(),
"an unresolvable sif target must fail the build, not silently succeed \
with the image left partially unnormalized"
);
}
#[test]
fn create_ext4_sizes_tree_containing_unreadable_dir() {
use std::os::unix::fs::PermissionsExt;
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: root can list a 0000 directory, so the bug cannot reproduce");
return;
}
let root = tempfile::tempdir().expect("tempdir");
let src = root.path().join("rootfs");
let data = src.join("data");
std::fs::create_dir_all(&data).expect("mkdir data");
for i in 0..200 {
let f = std::fs::File::create(data.join(format!("blob{i}"))).expect("create blob");
f.set_len(1024 * 1024).expect("size blob"); }
let secret = src.join("secret");
std::fs::create_dir_all(&secret).expect("mkdir secret");
std::fs::write(secret.join("locked"), b"x").expect("write locked");
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 secret");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let built = create_ext4_from_dir(&src, &out, 0);
let _ = std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o755));
let _disk = built
.expect("ext4 build must size the image from the real tree, not the fallback default");
let image_len = std::fs::metadata(&out).expect("stat image").len();
assert!(
image_len > MIN_DISK_SIZE_BYTES,
"image must be sized from the ~200 MiB tree, not clamped to the {} MiB floor (got {} MiB)",
MIN_DISK_SIZE_BYTES / (1024 * 1024),
image_len / (1024 * 1024)
);
}
#[test]
fn create_ext4_preserves_unreadable_file_mode() {
use std::os::unix::fs::PermissionsExt;
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!(
"skipping create_ext4_preserves_unreadable_file_mode: mke2fs/debugfs not found (run `make runtime:debug`)"
);
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!(
"skipping create_ext4_preserves_unreadable_file_mode: must run unprivileged to exercise the DAC read check"
);
return;
}
let src_root = tempfile::tempdir().expect("create source tempdir");
let src = src_root.path().join("rootfs");
std::fs::create_dir_all(src.join("etc")).expect("create etc/");
let gshadow = src.join("etc/gshadow");
let content = b"root:::\n";
std::fs::write(&gshadow, content).expect("write gshadow");
std::fs::set_permissions(&gshadow, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 gshadow");
let out_root = tempfile::tempdir().expect("create output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0)
.expect("ext4 build must tolerate a 0000-mode source file");
let debugfs = get_debugfs_path();
let stat = Command::new(&debugfs)
.args(["-R", "stat /etc/gshadow"])
.arg(&out)
.output()
.expect("run debugfs stat");
assert!(
stat.status.success(),
"debugfs stat failed: {}",
String::from_utf8_lossy(&stat.stderr)
);
let stat_out = String::from_utf8_lossy(&stat.stdout);
let tokens: Vec<&str> = stat_out.split_whitespace().collect();
let mode = tokens
.iter()
.position(|t| *t == "Mode:")
.and_then(|i| tokens.get(i + 1))
.copied()
.unwrap_or_else(|| panic!("no Mode field in debugfs stat:\n{stat_out}"));
assert_eq!(
mode, "0000",
"gshadow mode must stay 0000 in image:\n{stat_out}"
);
let cat = Command::new(&debugfs)
.args(["-R", "cat /etc/gshadow"])
.arg(&out)
.output()
.expect("run debugfs cat");
assert!(
cat.status.success(),
"debugfs cat failed: {}",
String::from_utf8_lossy(&cat.stderr)
);
assert_eq!(
cat.stdout, content,
"gshadow content must be preserved in image"
);
}
#[test]
fn create_ext4_restores_nested_unreadable_source_modes() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: must run unprivileged (root skips the widen)");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
let secret = src.join("etc/secret");
std::fs::create_dir_all(&secret).expect("mkdir tree");
let locked = secret.join("locked");
std::fs::write(&locked, b"x").expect("write locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 file");
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 dir");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0).expect("ext4 build must succeed");
assert_eq!(
std::fs::symlink_metadata(&secret).unwrap().mode() & 0o7777,
0o000,
"source dir mode must be restored to 0000"
);
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
std::fs::symlink_metadata(&locked).unwrap().mode() & 0o7777,
0o000,
"source file under a 0000 dir must be restored to 0000 (bottom-up restore)"
);
}
fn image_owner(image: &Path, ext4_path: &str) -> (String, String) {
let debugfs = get_debugfs_path();
let stat = Command::new(&debugfs)
.args(["-R", &format!("stat {}", ext4_path)])
.arg(image)
.output()
.expect("run debugfs stat");
assert!(
stat.status.success(),
"debugfs stat {} failed: {}",
ext4_path,
String::from_utf8_lossy(&stat.stderr)
);
let out = String::from_utf8_lossy(&stat.stdout);
let tokens: Vec<&str> = out.split_whitespace().collect();
let field = |name: &str| {
tokens
.iter()
.position(|t| *t == name)
.and_then(|i| tokens.get(i + 1))
.copied()
.unwrap_or_else(|| panic!("no {name} field in debugfs stat {ext4_path}:\n{out}"))
.to_string()
};
(field("User:"), field("Group:"))
}
#[test]
fn create_ext4_applies_override_stat_ownership() {
use crate::images::{OverrideFileType, OverrideStat};
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: must run unprivileged (as root ownership is applied directly)");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
let dex_dir = src.join("var/dex");
std::fs::create_dir_all(&dex_dir).expect("mkdir var/dex");
let dex_file = dex_dir.join("keep");
std::fs::write(&dex_file, b"x").expect("write keep");
std::fs::create_dir_all(src.join("etc")).expect("mkdir etc");
std::fs::write(src.join("etc/passwd"), b"root:x:0:0\n").expect("write passwd");
OverrideStat::new(1001, 1001, 0o755, OverrideFileType::Dir)
.write_xattr(&dex_dir)
.expect("write override_stat on var/dex");
OverrideStat::new(1001, 1001, 0o644, OverrideFileType::File)
.write_xattr(&dex_file)
.expect("write override_stat on var/dex/keep");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0).expect("ext4 build must succeed");
assert_eq!(
image_owner(&out, "/var/dex"),
("1001".to_string(), "1001".to_string()),
"a layer-chowned directory must keep its declared ownership in the image"
);
assert_eq!(
image_owner(&out, "/var/dex/keep"),
("1001".to_string(), "1001".to_string()),
"a layer-chowned file must keep its declared ownership in the image"
);
assert_eq!(
image_owner(&out, "/etc/passwd"),
("0".to_string(), "0".to_string()),
"an entry with no recorded ownership must still normalize to 0:0"
);
}
#[test]
fn scan_source_tree_fails_on_malformed_override_stat() {
const CONTAINERS_OVERRIDE_XATTR: &str = "user.containers.override_stat";
let root = tempfile::tempdir().expect("tempdir");
let src = root.path().join("rootfs");
std::fs::create_dir_all(&src).expect("mkdir rootfs");
let f = src.join("file");
std::fs::write(&f, b"x").expect("write file");
xattr::set(&f, CONTAINERS_OVERRIDE_XATTR, b"not-a-valid-record")
.expect("seed malformed xattr");
let mut widened = Vec::new();
let result = scan_source_tree(&src, &mut widened);
assert!(
result.is_err(),
"a malformed override_stat xattr is the only copy of a layer's \
declared ownership; the scan must fail, not silently default to 0:0"
);
}
#[test]
fn scan_source_tree_size_matches_standalone_walk() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("tempdir");
let src = root.path().join("rootfs");
std::fs::create_dir_all(src.join("etc/nested")).expect("mkdir tree");
std::fs::write(src.join("etc/small"), b"x").expect("write small");
std::fs::write(src.join("etc/nested/empty"), b"").expect("write empty");
std::fs::write(src.join("etc/nested/big"), vec![0u8; 9000]).expect("write big");
symlink("small", src.join("etc/link")).expect("symlink");
let mut widened = Vec::new();
let scan = scan_source_tree(&src, &mut widened).expect("scan must succeed");
assert!(widened.is_empty(), "nothing to widen in a readable tree");
assert_eq!(
scan.dir_size(),
calculate_dir_size(&src).expect("standalone walk must succeed"),
"merged scan must measure the tree exactly as the walk it replaced"
);
}
#[test]
fn scan_source_tree_widens_and_measures_zero_mode_dir_and_file() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let root = tempfile::tempdir().expect("tempdir");
let src = root.path().join("rootfs");
let secret_dir = src.join("etc/secret");
std::fs::create_dir_all(&secret_dir).expect("mkdir tree");
let locked = secret_dir.join("locked");
std::fs::write(&locked, b"x").expect("write locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 file");
std::fs::set_permissions(&secret_dir, std::fs::Permissions::from_mode(0o000))
.expect("chmod 0000 dir");
let mut widened = Vec::new();
let scan = scan_source_tree(&src, &mut widened).expect("scan must succeed as owner");
assert_eq!(
scan.entry_count, 4,
"every entry counted, including the root"
);
assert!(
scan.dir_size() > 0,
"a tree reached through a 0000 dir must measure non-zero"
);
let owned: Vec<&str> = scan.owners.iter().map(|o| o.ext4_path.as_str()).collect();
assert!(
owned.contains(&"/etc/secret"),
"0000 dir recorded: {owned:?}"
);
assert!(
owned.contains(&"/etc/secret/locked"),
"0000 file recorded: {owned:?}"
);
assert!(!owned.contains(&"/"), "source root excluded: {owned:?}");
assert_eq!(
std::fs::symlink_metadata(&secret_dir).unwrap().mode() & 0o500,
0o500
);
assert_eq!(
std::fs::symlink_metadata(&locked).unwrap().mode() & 0o400,
0o400
);
let dir_rec = widened
.iter()
.find(|w| w.ext4_path == "/etc/secret")
.expect("dir recorded");
assert_eq!(dir_rec.mode & 0o170000, 0o040000, "dir type bits preserved");
assert_eq!(dir_rec.mode & 0o7777, 0o000);
let file_rec = widened
.iter()
.find(|w| w.ext4_path == "/etc/secret/locked")
.expect("file recorded");
assert_eq!(
file_rec.mode & 0o170000,
0o100000,
"regular type bits preserved"
);
assert_eq!(file_rec.mode & 0o7777, 0o000);
std::fs::set_permissions(&secret_dir, std::fs::Permissions::from_mode(0o700)).ok();
}
#[test]
fn inject_file_into_ext4_fails_on_missing_host_file() {
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
std::fs::create_dir_all(&src).expect("mkdir rootfs");
std::fs::write(src.join("real"), b"x").expect("write real");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0).expect("ext4 build must succeed");
let missing_host_file = src_root.path().join("does-not-exist-on-host");
let result = inject_file_into_ext4(&out, &missing_host_file, "injected");
assert!(
result.is_err(),
"a missing host source file must fail the injection, not silently \
succeed with the guest path never actually written"
);
}
fn write_random_file(path: &Path, len: u64) {
use std::io::Read;
let mut urandom = std::fs::File::open("/dev/urandom").expect("open /dev/urandom");
let mut limited = (&mut urandom).take(len);
let mut out = std::fs::File::create(path).expect("create random payload file");
std::io::copy(&mut limited, &mut out).expect("write random payload");
}
#[test]
fn create_ext4_from_dir_reserves_headroom_for_post_build_injection() {
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
std::fs::create_dir_all(&src).expect("mkdir rootfs");
std::fs::write(src.join("real"), b"x").expect("write real");
let payload_len = 235 * 1024 * 1024u64;
let payload_root = tempfile::tempdir().expect("payload tempdir");
let payload = payload_root.path().join("guest-binary-stand-in");
write_random_file(&payload, payload_len);
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let reserve_bytes = payload_len + 8 * 1024 * 1024;
let _disk =
create_ext4_from_dir(&src, &out, reserve_bytes).expect("ext4 build must succeed");
let result = inject_file_into_ext4(&out, &payload, "boxlite/bin/boxlite-guest");
assert!(
result.is_ok(),
"a guest binary must always fit in the image it is injected into, \
but injection into an unpadded floor-sized image failed: {:?}",
result.err()
);
}
#[test]
fn test_build_inject_commands_nested_path() {
let cmds = build_inject_commands("/host/boxlite-guest", "boxlite/bin/boxlite-guest");
assert!(cmds.contains("mkdir /boxlite\n"));
assert!(cmds.contains("mkdir /boxlite/bin\n"));
assert!(cmds.contains("write \"/host/boxlite-guest\" /boxlite/bin/boxlite-guest\n"));
assert!(cmds.contains("sif /boxlite/bin/boxlite-guest uid 0\n"));
assert!(cmds.contains("sif /boxlite/bin/boxlite-guest gid 0\n"));
assert!(cmds.contains("sif /boxlite/bin/boxlite-guest mode 0100555\n"));
assert!(cmds.contains("sif /boxlite uid 0\n"));
assert!(cmds.contains("sif /boxlite gid 0\n"));
assert!(cmds.contains("sif /boxlite/bin uid 0\n"));
assert!(cmds.contains("sif /boxlite/bin gid 0\n"));
}
#[test]
fn test_build_inject_commands_single_dir() {
let cmds = build_inject_commands("/host/file", "dir/file");
assert!(cmds.contains("mkdir /dir\n"));
assert!(cmds.contains("write \"/host/file\" /dir/file\n"));
assert!(cmds.contains("sif /dir uid 0\n"));
assert!(cmds.contains("sif /dir gid 0\n"));
}
#[test]
fn test_build_inject_commands_root_level_file() {
let cmds = build_inject_commands("/host/file", "file");
assert!(!cmds.contains("mkdir"));
assert!(cmds.contains("write \"/host/file\" /file\n"));
assert!(cmds.contains("sif /file uid 0\n"));
assert!(cmds.contains("sif /file gid 0\n"));
assert!(cmds.contains("sif /file mode 0100555\n"));
}
#[test]
fn test_build_inject_commands_deeply_nested() {
let cmds = build_inject_commands("/src/bin", "a/b/c/d/bin");
assert!(cmds.contains("mkdir /a\n"));
assert!(cmds.contains("mkdir /a/b\n"));
assert!(cmds.contains("mkdir /a/b/c\n"));
assert!(cmds.contains("mkdir /a/b/c/d\n"));
assert!(cmds.contains("write \"/src/bin\" /a/b/c/d/bin\n"));
}
#[test]
fn test_build_inject_commands_path_with_spaces() {
let cmds = build_inject_commands(
"/Users/user/Library/Application Support/boxlite/runtimes/v0.6.0/boxlite-guest",
"boxlite/bin/boxlite-guest",
);
assert!(cmds.contains(
"write \"/Users/user/Library/Application Support/boxlite/runtimes/v0.6.0/boxlite-guest\" /boxlite/bin/boxlite-guest\n"
));
}
#[test]
fn normalize_inodes_stamps_image_uid_not_host_uid() {
if util::find_binary("mke2fs").is_err() || util::find_binary("debugfs").is_err() {
eprintln!("skipping: mke2fs/debugfs not found (run `make runtime:debug`)");
return;
}
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: root uses mke2fs -d which already writes correct ownership");
return;
}
let image_uid: u32 = 888;
let image_gid: u32 = 888;
let host_uid = unsafe { libc::getuid() };
if host_uid == image_uid {
eprintln!("skipping: host uid == image uid ({image_uid}); test would be vacuous");
return;
}
let src_root = tempfile::tempdir().expect("source tempdir");
let src = src_root.path().join("rootfs");
std::fs::create_dir_all(&src).expect("mkdir rootfs");
let file_path = src.join("image-owned");
std::fs::write(&file_path, b"data").expect("write file");
let stat = crate::images::OverrideStat::new(
image_uid,
image_gid,
0o644,
crate::images::OverrideFileType::File,
);
stat.write_xattr(&file_path)
.expect("set override_stat xattr");
let out_root = tempfile::tempdir().expect("output tempdir");
let out = out_root.path().join("rootfs.ext4");
let _disk = create_ext4_from_dir(&src, &out, 0).expect("ext4 build");
let debugfs = util::find_binary("debugfs").expect("debugfs");
let mut child = std::process::Command::new(&debugfs)
.args(["-f", "-"])
.arg(&out)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn debugfs");
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write as _;
stdin
.write_all(b"stat /image-owned\n")
.expect("write debugfs cmd");
}
let result = child.wait_with_output().expect("debugfs stat");
let all_output = format!(
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr),
);
let found_uid: Option<u32> = all_output.lines().find_map(|line| {
let after = line.split("User:").nth(1)?;
after.split_whitespace().next()?.parse().ok()
});
assert_eq!(
found_uid,
Some(image_uid),
"inode /image-owned must carry uid={image_uid} (image-declared), \
not host uid={host_uid}; debugfs output:\n{all_output}"
);
}
}