mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-27 05:53:10 +00:00
564 lines
18 KiB
Rust
564 lines
18 KiB
Rust
|
|
use crate::RuntimeError;
|
||
|
|
use std::fmt::Write as _;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::process::Command;
|
||
|
|
|
||
|
|
/// Shell-escape a string for safe interpolation into shell scripts.
|
||
|
|
/// Wraps the value in single quotes, escaping any embedded single quotes.
|
||
|
|
fn shell_quote(s: &str) -> String {
|
||
|
|
// Single-quoting in POSIX shell: replace ' with '\'' then wrap in '
|
||
|
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Shell-escape a Path for safe interpolation.
|
||
|
|
fn shell_quote_path(p: &Path) -> String {
|
||
|
|
shell_quote(&p.to_string_lossy())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct BindMount {
|
||
|
|
pub source: PathBuf,
|
||
|
|
pub target: PathBuf,
|
||
|
|
pub read_only: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct SandboxConfig {
|
||
|
|
pub rootfs: PathBuf,
|
||
|
|
pub overlay_lower: PathBuf,
|
||
|
|
pub overlay_upper: PathBuf,
|
||
|
|
pub overlay_work: PathBuf,
|
||
|
|
pub overlay_merged: PathBuf,
|
||
|
|
pub hostname: String,
|
||
|
|
pub bind_mounts: Vec<BindMount>,
|
||
|
|
pub env_vars: Vec<(String, String)>,
|
||
|
|
pub isolate_network: bool,
|
||
|
|
pub uid: u32,
|
||
|
|
pub gid: u32,
|
||
|
|
pub username: String,
|
||
|
|
pub home_dir: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Safe wrapper around libc::getuid().
|
||
|
|
#[allow(unsafe_code)]
|
||
|
|
fn current_uid() -> u32 {
|
||
|
|
// SAFETY: getuid() is always safe — no arguments, no side effects, cannot fail.
|
||
|
|
unsafe { libc::getuid() }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Safe wrapper around libc::getgid().
|
||
|
|
#[allow(unsafe_code)]
|
||
|
|
fn current_gid() -> u32 {
|
||
|
|
// SAFETY: getgid() is always safe — no arguments, no side effects, cannot fail.
|
||
|
|
unsafe { libc::getgid() }
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SandboxConfig {
|
||
|
|
pub fn new(rootfs: PathBuf, env_id: &str, env_dir: &Path) -> Self {
|
||
|
|
let uid = current_uid();
|
||
|
|
let gid = current_gid();
|
||
|
|
let username = std::env::var("USER").unwrap_or_else(|_| "user".to_owned());
|
||
|
|
let home_dir =
|
||
|
|
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| format!("/home/{username}")));
|
||
|
|
|
||
|
|
Self {
|
||
|
|
rootfs,
|
||
|
|
overlay_lower: env_dir.join("lower"),
|
||
|
|
overlay_upper: env_dir.join("upper"),
|
||
|
|
overlay_work: env_dir.join("work"),
|
||
|
|
overlay_merged: env_dir.join("merged"),
|
||
|
|
hostname: format!("karapace-{}", &env_id[..12.min(env_id.len())]),
|
||
|
|
bind_mounts: Vec::new(),
|
||
|
|
env_vars: Vec::new(),
|
||
|
|
isolate_network: false,
|
||
|
|
uid,
|
||
|
|
gid,
|
||
|
|
username,
|
||
|
|
home_dir,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn mount_overlay(config: &SandboxConfig) -> Result<(), RuntimeError> {
|
||
|
|
// Unmount any stale overlay from a previous failed run
|
||
|
|
let _ = unmount_overlay(config);
|
||
|
|
|
||
|
|
// Clean stale work dir (fuse-overlayfs requires a clean workdir)
|
||
|
|
if config.overlay_work.exists() {
|
||
|
|
let _ = std::fs::remove_dir_all(&config.overlay_work);
|
||
|
|
}
|
||
|
|
|
||
|
|
for dir in [
|
||
|
|
&config.overlay_upper,
|
||
|
|
&config.overlay_work,
|
||
|
|
&config.overlay_merged,
|
||
|
|
] {
|
||
|
|
std::fs::create_dir_all(dir)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create a symlink to rootfs as lower dir if needed
|
||
|
|
if !config.overlay_lower.exists() {
|
||
|
|
#[cfg(unix)]
|
||
|
|
std::os::unix::fs::symlink(&config.rootfs, &config.overlay_lower)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
let status = Command::new("fuse-overlayfs")
|
||
|
|
.args([
|
||
|
|
"-o",
|
||
|
|
&format!(
|
||
|
|
"lowerdir={},upperdir={},workdir={}",
|
||
|
|
config.rootfs.display(),
|
||
|
|
config.overlay_upper.display(),
|
||
|
|
config.overlay_work.display()
|
||
|
|
),
|
||
|
|
&config.overlay_merged.to_string_lossy(),
|
||
|
|
])
|
||
|
|
.status()
|
||
|
|
.map_err(|e| {
|
||
|
|
RuntimeError::ExecFailed(format!(
|
||
|
|
"fuse-overlayfs not found or failed to start: {e}. Install with: sudo zypper install fuse-overlayfs"
|
||
|
|
))
|
||
|
|
})?;
|
||
|
|
|
||
|
|
if !status.success() {
|
||
|
|
return Err(RuntimeError::ExecFailed(
|
||
|
|
"fuse-overlayfs mount failed".to_owned(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if a path is currently a mount point by inspecting /proc/mounts.
|
||
|
|
fn is_mounted(path: &Path) -> bool {
|
||
|
|
let canonical = match std::fs::canonicalize(path) {
|
||
|
|
Ok(p) => p.to_string_lossy().to_string(),
|
||
|
|
Err(_) => path.to_string_lossy().to_string(),
|
||
|
|
};
|
||
|
|
match std::fs::read_to_string("/proc/mounts") {
|
||
|
|
Ok(mounts) => mounts
|
||
|
|
.lines()
|
||
|
|
.any(|line| line.split_whitespace().nth(1) == Some(&canonical)),
|
||
|
|
Err(_) => false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn unmount_overlay(config: &SandboxConfig) -> Result<(), RuntimeError> {
|
||
|
|
if !config.overlay_merged.exists() {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
// Only attempt unmount if actually mounted (avoids spurious errors)
|
||
|
|
if !is_mounted(&config.overlay_merged) {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
let _ = Command::new("fusermount3")
|
||
|
|
.args(["-u", &config.overlay_merged.to_string_lossy()])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status();
|
||
|
|
// Fallback if fusermount3 is not available
|
||
|
|
if is_mounted(&config.overlay_merged) {
|
||
|
|
let _ = Command::new("fusermount")
|
||
|
|
.args(["-u", &config.overlay_merged.to_string_lossy()])
|
||
|
|
.stdout(std::process::Stdio::null())
|
||
|
|
.stderr(std::process::Stdio::null())
|
||
|
|
.status();
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn setup_container_rootfs(config: &SandboxConfig) -> Result<PathBuf, RuntimeError> {
|
||
|
|
let merged = &config.overlay_merged;
|
||
|
|
|
||
|
|
// Essential directories inside the container
|
||
|
|
for subdir in [
|
||
|
|
"proc", "sys", "dev", "dev/pts", "dev/shm", "tmp", "run", "run/user", "etc", "var",
|
||
|
|
"var/tmp",
|
||
|
|
] {
|
||
|
|
std::fs::create_dir_all(merged.join(subdir))?;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create run/user/<uid> for XDG_RUNTIME_DIR
|
||
|
|
let user_run = merged.join(format!("run/user/{}", config.uid));
|
||
|
|
std::fs::create_dir_all(&user_run)?;
|
||
|
|
|
||
|
|
// Create home directory
|
||
|
|
let container_home = merged.join(
|
||
|
|
config
|
||
|
|
.home_dir
|
||
|
|
.strip_prefix("/")
|
||
|
|
.unwrap_or(&config.home_dir),
|
||
|
|
);
|
||
|
|
std::fs::create_dir_all(&container_home)?;
|
||
|
|
|
||
|
|
// Write /etc/hostname
|
||
|
|
let _ = std::fs::write(merged.join("etc/hostname"), &config.hostname);
|
||
|
|
|
||
|
|
// Ensure /etc/resolv.conf exists (copy from host for DNS)
|
||
|
|
if !merged.join("etc/resolv.conf").exists() && Path::new("/etc/resolv.conf").exists() {
|
||
|
|
let _ = std::fs::copy("/etc/resolv.conf", merged.join("etc/resolv.conf"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure user exists in /etc/passwd
|
||
|
|
ensure_user_in_container(config, merged)?;
|
||
|
|
|
||
|
|
Ok(merged.clone())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn ensure_user_in_container(config: &SandboxConfig, merged: &Path) -> Result<(), RuntimeError> {
|
||
|
|
let passwd_path = merged.join("etc/passwd");
|
||
|
|
let existing = std::fs::read_to_string(&passwd_path).unwrap_or_default();
|
||
|
|
|
||
|
|
let user_entry = format!(
|
||
|
|
"{}:x:{}:{}::/{}:/bin/bash\n",
|
||
|
|
config.username,
|
||
|
|
config.uid,
|
||
|
|
config.gid,
|
||
|
|
config
|
||
|
|
.home_dir
|
||
|
|
.strip_prefix("/")
|
||
|
|
.unwrap_or(&config.home_dir)
|
||
|
|
.display()
|
||
|
|
);
|
||
|
|
|
||
|
|
if !existing.contains(&format!("{}:", config.username)) {
|
||
|
|
let mut content = existing;
|
||
|
|
if !content.contains("root:") {
|
||
|
|
content.push_str("root:x:0:0:root:/root:/bin/bash\n");
|
||
|
|
}
|
||
|
|
content.push_str(&user_entry);
|
||
|
|
std::fs::write(&passwd_path, content)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure group exists
|
||
|
|
let group_path = merged.join("etc/group");
|
||
|
|
let existing_groups = std::fs::read_to_string(&group_path).unwrap_or_default();
|
||
|
|
let group_entry = format!("{}:x:{}:\n", config.username, config.gid);
|
||
|
|
if !existing_groups.contains(&format!("{}:", config.username)) {
|
||
|
|
let mut content = existing_groups;
|
||
|
|
if !content.contains("root:") {
|
||
|
|
content.push_str("root:x:0:\n");
|
||
|
|
}
|
||
|
|
content.push_str(&group_entry);
|
||
|
|
std::fs::write(&group_path, content)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_unshare_command(config: &SandboxConfig) -> Command {
|
||
|
|
let mut cmd = Command::new("unshare");
|
||
|
|
cmd.args(["--user", "--map-root-user", "--mount", "--pid", "--fork"]);
|
||
|
|
|
||
|
|
if config.isolate_network {
|
||
|
|
cmd.arg("--net");
|
||
|
|
}
|
||
|
|
|
||
|
|
cmd
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_setup_script(config: &SandboxConfig) -> String {
|
||
|
|
let merged = &config.overlay_merged;
|
||
|
|
let qm = shell_quote_path(merged);
|
||
|
|
let mut script = String::new();
|
||
|
|
|
||
|
|
// Mount /proc
|
||
|
|
let _ = writeln!(script, "mount -t proc proc {qm}/proc 2>/dev/null || true");
|
||
|
|
|
||
|
|
// Bind mount /sys (read-only)
|
||
|
|
let _ = writeln!(script, "mount --rbind /sys {qm}/sys 2>/dev/null && mount --make-rslave {qm}/sys 2>/dev/null || true");
|
||
|
|
|
||
|
|
// Bind mount /dev
|
||
|
|
let _ = writeln!(script, "mount --rbind /dev {qm}/dev 2>/dev/null && mount --make-rslave {qm}/dev 2>/dev/null || true");
|
||
|
|
|
||
|
|
// Bind mount home directory
|
||
|
|
let container_home = merged.join(
|
||
|
|
config
|
||
|
|
.home_dir
|
||
|
|
.strip_prefix("/")
|
||
|
|
.unwrap_or(&config.home_dir),
|
||
|
|
);
|
||
|
|
let _ = writeln!(
|
||
|
|
script,
|
||
|
|
"mount --bind {} {} 2>/dev/null || true",
|
||
|
|
shell_quote_path(&config.home_dir),
|
||
|
|
shell_quote_path(&container_home)
|
||
|
|
);
|
||
|
|
|
||
|
|
// Bind mount /etc/resolv.conf for DNS resolution
|
||
|
|
let _ = writeln!(script, "touch {qm}/etc/resolv.conf 2>/dev/null; mount --bind /etc/resolv.conf {qm}/etc/resolv.conf 2>/dev/null || true");
|
||
|
|
|
||
|
|
// Bind mount /tmp
|
||
|
|
let _ = writeln!(script, "mount --bind /tmp {qm}/tmp 2>/dev/null || true");
|
||
|
|
|
||
|
|
// Bind mounts from config (user-supplied paths — must be quoted)
|
||
|
|
for bm in &config.bind_mounts {
|
||
|
|
let target = if bm.target.is_absolute() {
|
||
|
|
merged.join(bm.target.strip_prefix("/").unwrap_or(&bm.target))
|
||
|
|
} else {
|
||
|
|
merged.join(&bm.target)
|
||
|
|
};
|
||
|
|
let qt = shell_quote_path(&target);
|
||
|
|
let qs = shell_quote_path(&bm.source);
|
||
|
|
let _ = writeln!(
|
||
|
|
script,
|
||
|
|
"mkdir -p {qt} 2>/dev/null; mount --bind {qs} {qt} 2>/dev/null || true"
|
||
|
|
);
|
||
|
|
if bm.read_only {
|
||
|
|
let _ = writeln!(script, "mount -o remount,ro,bind {qt} 2>/dev/null || true");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bind mount XDG_RUNTIME_DIR sockets (Wayland, PipeWire, D-Bus)
|
||
|
|
if let Ok(xdg_run) = std::env::var("XDG_RUNTIME_DIR") {
|
||
|
|
let container_run = merged.join(format!("run/user/{}", config.uid));
|
||
|
|
for socket in &["wayland-0", "pipewire-0", "pulse/native", "bus"] {
|
||
|
|
let src = PathBuf::from(&xdg_run).join(socket);
|
||
|
|
if src.exists() {
|
||
|
|
let dst = container_run.join(socket);
|
||
|
|
let qs = shell_quote_path(&src);
|
||
|
|
let qd = shell_quote_path(&dst);
|
||
|
|
if let Some(parent) = dst.parent() {
|
||
|
|
let _ = writeln!(
|
||
|
|
script,
|
||
|
|
"mkdir -p {} 2>/dev/null || true",
|
||
|
|
shell_quote_path(parent)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
// For sockets, touch the target first
|
||
|
|
if src.is_file() || !src.is_dir() {
|
||
|
|
let _ = writeln!(script, "touch {qd} 2>/dev/null || true");
|
||
|
|
}
|
||
|
|
let _ = writeln!(script, "mount --bind {qs} {qd} 2>/dev/null || true");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bind mount X11 socket if present
|
||
|
|
if Path::new("/tmp/.X11-unix").exists() {
|
||
|
|
let _ = writeln!(
|
||
|
|
script,
|
||
|
|
"mount --bind /tmp/.X11-unix {qm}/tmp/.X11-unix 2>/dev/null || true"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chroot and exec
|
||
|
|
let _ = write!(script, "exec chroot {qm} /bin/sh -c '");
|
||
|
|
|
||
|
|
script
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn enter_interactive(config: &SandboxConfig) -> Result<i32, RuntimeError> {
|
||
|
|
let merged = &config.overlay_merged;
|
||
|
|
|
||
|
|
let mut setup = build_setup_script(config);
|
||
|
|
|
||
|
|
// Build environment variable exports (all values shell-quoted, keys validated)
|
||
|
|
let mut env_exports = String::new();
|
||
|
|
for (key, val) in &config.env_vars {
|
||
|
|
if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
||
|
|
continue; // Skip keys with unsafe characters
|
||
|
|
}
|
||
|
|
let _ = write!(env_exports, "export {}={}; ", key, shell_quote(val));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set standard env vars (all values shell-quoted)
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export HOME={}; ",
|
||
|
|
shell_quote_path(&config.home_dir)
|
||
|
|
);
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export USER={}; ",
|
||
|
|
shell_quote(&config.username)
|
||
|
|
);
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export HOSTNAME={}; ",
|
||
|
|
shell_quote(&config.hostname)
|
||
|
|
);
|
||
|
|
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export XDG_RUNTIME_DIR={}; ",
|
||
|
|
shell_quote(&xdg)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if let Ok(display) = std::env::var("DISPLAY") {
|
||
|
|
let _ = write!(env_exports, "export DISPLAY={}; ", shell_quote(&display));
|
||
|
|
}
|
||
|
|
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export WAYLAND_DISPLAY={}; ",
|
||
|
|
shell_quote(&wayland)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
env_exports.push_str("export TERM=${TERM:-xterm-256color}; ");
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export KARAPACE_ENV=1; export KARAPACE_HOSTNAME={}; ",
|
||
|
|
shell_quote(&config.hostname)
|
||
|
|
);
|
||
|
|
|
||
|
|
// Determine shell
|
||
|
|
let shell = if merged.join("bin/bash").exists() || merged.join("usr/bin/bash").exists() {
|
||
|
|
"/bin/bash"
|
||
|
|
} else {
|
||
|
|
"/bin/sh"
|
||
|
|
};
|
||
|
|
|
||
|
|
let _ = write!(setup, "{env_exports}cd ~; exec {shell} -l'");
|
||
|
|
|
||
|
|
let mut cmd = build_unshare_command(config);
|
||
|
|
cmd.arg("/bin/sh").arg("-c").arg(&setup);
|
||
|
|
|
||
|
|
// Pass through stdin/stdout/stderr for interactive use
|
||
|
|
cmd.stdin(std::process::Stdio::inherit());
|
||
|
|
cmd.stdout(std::process::Stdio::inherit());
|
||
|
|
cmd.stderr(std::process::Stdio::inherit());
|
||
|
|
|
||
|
|
let status = cmd
|
||
|
|
.status()
|
||
|
|
.map_err(|e| RuntimeError::ExecFailed(format!("failed to enter sandbox: {e}")))?;
|
||
|
|
|
||
|
|
Ok(status.code().unwrap_or(1))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn exec_in_container(
|
||
|
|
config: &SandboxConfig,
|
||
|
|
command: &[String],
|
||
|
|
) -> Result<std::process::Output, RuntimeError> {
|
||
|
|
let mut setup = build_setup_script(config);
|
||
|
|
|
||
|
|
// Environment (all values shell-quoted, keys validated)
|
||
|
|
let mut env_exports = String::new();
|
||
|
|
for (key, val) in &config.env_vars {
|
||
|
|
if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
||
|
|
continue; // Skip keys with unsafe characters
|
||
|
|
}
|
||
|
|
let _ = write!(env_exports, "export {}={}; ", key, shell_quote(val));
|
||
|
|
}
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export HOME={}; ",
|
||
|
|
shell_quote_path(&config.home_dir)
|
||
|
|
);
|
||
|
|
let _ = write!(
|
||
|
|
env_exports,
|
||
|
|
"export USER={}; ",
|
||
|
|
shell_quote(&config.username)
|
||
|
|
);
|
||
|
|
env_exports.push_str("export KARAPACE_ENV=1; ");
|
||
|
|
|
||
|
|
let escaped_cmd: Vec<String> = command.iter().map(|a| shell_quote(a)).collect();
|
||
|
|
let _ = write!(setup, "{env_exports}{}'", escaped_cmd.join(" "));
|
||
|
|
|
||
|
|
let mut cmd = build_unshare_command(config);
|
||
|
|
cmd.arg("/bin/sh").arg("-c").arg(&setup);
|
||
|
|
|
||
|
|
cmd.output()
|
||
|
|
.map_err(|e| RuntimeError::ExecFailed(format!("exec in container failed: {e}")))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn install_packages_in_container(
|
||
|
|
config: &SandboxConfig,
|
||
|
|
install_cmd: &[String],
|
||
|
|
) -> Result<(), RuntimeError> {
|
||
|
|
if install_cmd.is_empty() {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
|
||
|
|
let output = exec_in_container(config, install_cmd)?;
|
||
|
|
|
||
|
|
if !output.status.success() {
|
||
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
|
|
return Err(RuntimeError::ExecFailed(format!(
|
||
|
|
"package installation failed:\nstdout: {stdout}\nstderr: {stderr}"
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sandbox_config_defaults() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
let rootfs = dir.path().join("rootfs");
|
||
|
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||
|
|
let config = SandboxConfig::new(rootfs, "abc123def456", dir.path());
|
||
|
|
assert!(config.hostname.starts_with("karapace-"));
|
||
|
|
assert!(!config.isolate_network);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn shell_quote_escapes_single_quotes() {
|
||
|
|
assert_eq!(shell_quote("hello"), "'hello'");
|
||
|
|
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||
|
|
assert_eq!(shell_quote(""), "''");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn shell_quote_prevents_injection() {
|
||
|
|
// Command substitution is safely wrapped in single quotes
|
||
|
|
let malicious = "$(rm -rf /)";
|
||
|
|
let quoted = shell_quote(malicious);
|
||
|
|
assert_eq!(quoted, "'$(rm -rf /)'");
|
||
|
|
assert!(quoted.starts_with('\'') && quoted.ends_with('\''));
|
||
|
|
|
||
|
|
// Backtick injection is also safely quoted
|
||
|
|
let backtick = "`whoami`";
|
||
|
|
let quoted = shell_quote(backtick);
|
||
|
|
assert_eq!(quoted, "'`whoami`'");
|
||
|
|
|
||
|
|
// Newline injection
|
||
|
|
let newline = "value\n; rm -rf /";
|
||
|
|
let quoted = shell_quote(newline);
|
||
|
|
assert!(quoted.starts_with('\'') && quoted.ends_with('\''));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn shell_quote_path_handles_spaces() {
|
||
|
|
let p = PathBuf::from("/home/user/my project/dir");
|
||
|
|
let quoted = shell_quote_path(&p);
|
||
|
|
assert_eq!(quoted, "'/home/user/my project/dir'");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn build_setup_script_contains_essential_mounts() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
let rootfs = dir.path().join("rootfs");
|
||
|
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||
|
|
let config = SandboxConfig::new(rootfs, "abc123def456", dir.path());
|
||
|
|
let script = build_setup_script(&config);
|
||
|
|
assert!(script.contains("mount -t proc"));
|
||
|
|
assert!(script.contains("mount --rbind /sys"));
|
||
|
|
assert!(script.contains("mount --rbind /dev"));
|
||
|
|
assert!(script.contains("chroot"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn is_mounted_returns_false_for_regular_dir() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
assert!(!is_mounted(dir.path()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn unmount_overlay_noop_on_non_mounted() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
let rootfs = dir.path().join("rootfs");
|
||
|
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||
|
|
let config = SandboxConfig::new(rootfs, "abc123def456", dir.path());
|
||
|
|
// Create the merged dir but don't mount anything
|
||
|
|
std::fs::create_dir_all(&config.overlay_merged).unwrap();
|
||
|
|
// Should not error — just returns Ok because nothing is mounted
|
||
|
|
assert!(unmount_overlay(&config).is_ok());
|
||
|
|
}
|
||
|
|
}
|