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) -> u64 {
let dir_size = calculate_dir_size(source).unwrap_or(DEFAULT_DIR_SIZE_BYTES);
let size_with_overhead =
dir_size * SIZE_MULTIPLIER_NUM / SIZE_MULTIPLIER_DEN + JOURNAL_OVERHEAD_BYTES;
let final_size = size_with_overhead.max(MIN_DISK_SIZE_BYTES);
tracing::debug!(
"Calculated disk size: dir_size={}MB, with_overhead={}MB, final={}MB",
dir_size / (1024 * 1024),
size_with_overhead / (1024 * 1024),
final_size / (1024 * 1024)
);
final_size
}
struct WidenedPerm {
ext4_path: String,
source_path: PathBuf,
mode: u32,
}
fn widen_unreadable_owner(source: &Path, widened: &mut Vec<WidenedPerm>) -> BoxliteResult<()> {
widen_dir_recursive(source, source, widened)
}
fn widen_dir_recursive(
source_root: &Path,
dir: &Path,
widened: &mut Vec<WidenedPerm>,
) -> BoxliteResult<()> {
use std::os::unix::fs::MetadataExt;
let dir_mode = std::fs::symlink_metadata(dir)
.map_err(|e| BoxliteError::Storage(format!("Failed to stat {}: {}", dir.display(), e)))?
.mode();
if dir_mode & 0o500 != 0o500 {
record_and_widen(source_root, dir, dir_mode, dir_mode | 0o500, widened)?;
}
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_symlink() {
continue;
}
if file_type.is_dir() {
widen_dir_recursive(source_root, &path, widened)?;
} else if file_type.is_file() && meta.mode() & 0o400 == 0 {
record_and_widen(
source_root,
&path,
meta.mode(),
meta.mode() | 0o400,
widened,
)?;
}
}
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) -> BoxliteResult<Disk> {
let size_bytes = calculate_disk_size(source);
let size_blocks = size_bytes / 4096;
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(),
};
if unsafe { libc::geteuid() } != 0 {
widen_unreadable_owner(source, &mut source_modes.widened)?;
}
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
)));
}
normalize_inodes_with_debugfs(output_path, source, &source_modes.widened)?;
let disk = Disk::new(output_path.to_path_buf(), DiskFormat::Ext4, false);
Ok(disk)
}
fn normalize_inodes_with_debugfs(
image_path: &Path,
source_dir: &Path,
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 mut paths = Vec::new();
for entry in WalkDir::new(source_dir).follow_links(false) {
let entry =
entry.map_err(|e| BoxliteError::Storage(format!("Failed to walk directory: {}", e)))?;
let rel_path = entry
.path()
.strip_prefix(source_dir)
.unwrap_or(entry.path());
if rel_path.as_os_str().is_empty() {
continue;
}
let ext4_path = format!("/{}", rel_path.display());
paths.push(ext4_path);
}
if paths.is_empty() && widened.is_empty() {
tracing::debug!("No inodes to normalize");
return Ok(());
}
let mut commands = String::new();
for path in &paths {
commands.push_str(&format!("sif {} uid 0\n", path));
commands.push_str(&format!("sif {} gid 0\n", path));
}
for w in widened {
commands.push_str(&format!("sif {} mode 0{:o}\n", w.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();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BoxliteError::Storage(format!(
"debugfs inode normalization failed (exit {:?}) on {}: {}",
output.status.code(),
image_path.display(),
stderr
)));
}
tracing::info!(
"Normalized {} inodes to 0:0 ({} mode-restored) in {:?}",
paths.len(),
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)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BoxliteError::Storage(format!(
"debugfs injection failed for {} -> {}: {}",
host_file.display(),
guest_path,
stderr
)));
}
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 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)
.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).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)"
);
}
#[test]
fn widen_unreadable_owner_handles_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();
widen_unreadable_owner(&src, &mut widened).expect("widen must succeed as owner");
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 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"
));
}
}