use anyhow::{Context, Result};
use clap::Parser;
use nix::unistd::{Gid, Uid};
use std::ffi::CString;
use std::path::Path;
mod archive;
mod b64stream;
mod namespace;
mod procdir;
#[derive(Parser, Debug)]
#[command(name = "fuselage", version, about)]
struct Args {
#[arg(short = 'd', long = "dynamic", value_name = "[NAME:]FILE")]
dynamic: Vec<String>,
#[arg(short = 's', long = "static", value_name = "[NAME:]FILE")]
r#static: Vec<String>,
#[arg(long = "cache-static")]
cache_static: bool,
#[arg(long = "run", value_name = "PATH")]
run: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.run.is_some() && args.dynamic.is_empty() && args.r#static.is_empty() {
anyhow::bail!("--run requires at least one --static or --dynamic archive");
}
if args.run.is_none() && args.command.is_empty() {
anyhow::bail!("no command specified; use -- COMMAND or --run PATH");
}
let ruid = nix::unistd::getuid();
let rgid = nix::unistd::getgid();
let euid = nix::unistd::geteuid();
let is_privileged = euid.is_root();
let is_setuid = is_privileged && !ruid.is_root();
if is_setuid {
nix::unistd::seteuid(ruid).context("seteuid: failed to drop to real uid")?;
}
let home = procdir::fuselage_home();
procdir::setup_home(&home)?;
procdir::clean_stale_procdirs(&home)?;
let pd = procdir::create_procdir(&home)?;
if is_setuid {
nix::unistd::seteuid(Uid::from_raw(0)).context("seteuid: failed to restore root")?;
}
namespace::enter_namespace()?;
procdir::setup_procdir_in_namespace(&pd)?;
let tmpdir = pd.join("tmp");
let mut seen_names: Vec<String> = Vec::new();
let dynamic_specs = parse_archive_specs(&args.dynamic, &mut seen_names)?;
let static_specs = parse_archive_specs(&args.r#static, &mut seen_names)?;
let dynamic_root = pd.join("dynamic");
if !dynamic_specs.is_empty() {
for spec in &dynamic_specs {
let dest = dynamic_root.join(&spec.name);
std::fs::create_dir_all(&dest)
.with_context(|| format!("failed to create {}", dest.display()))?;
let (archive_file, fmt) = resolve_archive(spec, &pd.join("decode"))?;
match fmt {
archive::ArchiveFormat::Zip => archive::extract_zip(&archive_file, &dest)?,
archive::ArchiveFormat::Squashfs => {
archive::extract_squashfs(&archive_file, &dest)?
}
}
}
unsafe { std::env::set_var("FUSELAGE_DYNAMIC", &dynamic_root) };
}
let static_root = pd.join("static");
let cache_dir = procdir::cache_dir(&home);
enum MountAction {
LoopSfs(std::path::PathBuf), ExtractSfsBindRo(std::path::PathBuf), BindRoSelf, BindRoFrom(std::path::PathBuf), }
let mut mount_actions: Vec<(std::path::PathBuf, MountAction)> = Vec::new();
if !static_specs.is_empty() {
for spec in &static_specs {
let dest = static_root.join(&spec.name);
std::fs::create_dir_all(&dest)
.with_context(|| format!("failed to create {}", dest.display()))?;
let (archive_file, fmt) = resolve_archive(spec, &pd.join("decode"))?;
let action = match fmt {
archive::ArchiveFormat::Squashfs => {
if is_privileged {
MountAction::LoopSfs(archive_file)
} else {
MountAction::ExtractSfsBindRo(archive_file)
}
}
archive::ArchiveFormat::Zip if !args.cache_static => {
archive::extract_zip(&archive_file, &dest)?;
MountAction::BindRoSelf
}
archive::ArchiveFormat::Zip => {
std::fs::create_dir_all(&cache_dir)?;
let hash = archive::compute_sha256(&archive_file)?;
let sfs_path = cache_dir.join(format!("{hash}.sfs"));
let dir_path = cache_dir.join(&hash);
let sentinel = cache_dir.join(format!("{hash}.complete"));
if !sentinel.exists() {
let tmp = pd.join(format!(".tmp-{}", spec.name));
std::fs::create_dir_all(&tmp)?;
let built_sfs = archive::zip_to_squashfs(&archive_file, &sfs_path, &tmp)?;
if !built_sfs {
archive::extract_zip(&archive_file, &dir_path)?;
}
std::fs::remove_dir_all(&tmp).ok();
std::fs::File::create(&sentinel)
.context("failed to write cache sentinel")?;
} else {
procdir::touch_sentinel(&sentinel)?;
}
if sfs_path.exists() {
if is_privileged {
MountAction::LoopSfs(sfs_path)
} else {
MountAction::ExtractSfsBindRo(sfs_path)
}
} else {
MountAction::BindRoFrom(dir_path)
}
}
};
mount_actions.push((dest, action));
}
unsafe { std::env::set_var("FUSELAGE_STATIC", &static_root) };
}
if is_setuid {
procdir::chown_recursive(&pd, ruid, rgid)
.context("failed to chown procdir to real user")?;
}
for (dest, action) in mount_actions {
match action {
MountAction::LoopSfs(sfs) => {
procdir::loop_mount_sfs(&sfs, &dest)?;
}
MountAction::ExtractSfsBindRo(sfs) => {
archive::extract_squashfs(&sfs, &dest)?;
procdir::bind_mount_readonly(&dest)?;
}
MountAction::BindRoSelf => {
procdir::bind_mount_readonly(&dest)?;
}
MountAction::BindRoFrom(src) => {
procdir::bind_mount_readonly_from(&src, &dest)?;
}
}
}
unsafe { std::env::set_var("FUSELAGE_TMPDIR", &tmpdir) };
let (exec_path, extra_args): (String, &[String]) = if let Some(ref run_path) = args.run {
let resolved = resolve_run_path(
run_path,
&dynamic_specs,
&static_specs,
&pd.join("dynamic"),
&static_root,
)?;
(resolved, &args.command)
} else {
(args.command[0].clone(), &args.command[1..])
};
let prog = CString::new(exec_path.as_str())
.with_context(|| format!("command contains a null byte: {exec_path:?}"))?;
let mut argv: Vec<CString> = Vec::with_capacity(1 + extra_args.len());
argv.push(prog.clone());
for arg in extra_args {
argv.push(
CString::new(arg.as_str())
.with_context(|| format!("argument contains a null byte: {arg:?}"))?,
);
}
let drop_to = is_setuid.then_some((ruid, rgid));
run_with_cleanup(&prog, &argv, &pd, drop_to, &cache_dir)
}
fn resolve_archive(
spec: &archive::ArchiveSpec,
decode_dir: &Path,
) -> Result<(std::path::PathBuf, archive::ArchiveFormat)> {
if let Ok(fmt) = archive::detect_format(&spec.file) {
return Ok((spec.file.clone(), fmt));
}
std::fs::create_dir_all(decode_dir)
.with_context(|| format!("failed to create decode dir {}", decode_dir.display()))?;
let decoded = decode_dir.join(format!("{}.decoded", spec.name));
let is_b64 = archive::try_decode_base64(&spec.file, &decoded)?;
if !is_b64 {
anyhow::bail!(
"{}: unrecognised archive format (not zip, squashfs, or base64)",
spec.file.display()
);
}
let fmt = archive::detect_format(&decoded).with_context(|| {
format!(
"{}: base64 decoded to an unrecognised archive format",
spec.file.display()
)
})?;
Ok((decoded, fmt))
}
fn parse_archive_specs(
raw: &[String],
seen: &mut Vec<String>,
) -> Result<Vec<archive::ArchiveSpec>> {
let mut specs = Vec::new();
for arg in raw {
let spec = archive::ArchiveSpec::parse(arg)?;
if seen.contains(&spec.name) {
anyhow::bail!(
"duplicate archive name '{}'; use NAME: prefix to disambiguate",
spec.name
);
}
seen.push(spec.name.clone());
specs.push(spec);
}
Ok(specs)
}
fn resolve_run_path(
path: &str,
dynamic_specs: &[archive::ArchiveSpec],
static_specs: &[archive::ArchiveSpec],
dynamic_root: &std::path::Path,
static_root: &std::path::Path,
) -> Result<String> {
let p = std::path::Path::new(path);
if p.is_absolute() {
anyhow::bail!("--run path must be relative, got: {path:?}");
}
let mut components = p.components();
let first = match components.next() {
Some(std::path::Component::Normal(c)) => c.to_string_lossy().into_owned(),
_ => anyhow::bail!("--run path must begin with an archive name, got: {path:?}"),
};
let root = if dynamic_specs.iter().any(|s| s.name == first) {
dynamic_root
} else if static_specs.iter().any(|s| s.name == first) {
static_root
} else {
anyhow::bail!(
"--run: first path component {first:?} does not match any mounted archive name"
);
};
let full = root.join(path);
if !full.exists() {
anyhow::bail!("--run: path does not exist: {}", full.display());
}
if !full.is_file() {
anyhow::bail!("--run: path is not a file: {}", full.display());
}
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&full)?.permissions().mode();
if mode & 0o111 == 0 {
anyhow::bail!("--run: file is not executable: {}", full.display());
}
Ok(full.to_string_lossy().into_owned())
}
fn run_with_cleanup(
prog: &CString,
argv: &[CString],
procdir: &Path,
drop_to: Option<(Uid, Gid)>,
cache_dir: &Path,
) -> Result<()> {
use nix::sys::wait::{WaitStatus, waitpid};
use nix::unistd::{ForkResult, fork};
match unsafe { fork() }.context("fork failed")? {
ForkResult::Child => {
if let Some((uid, gid)) = drop_to {
if let Err(e) = nix::unistd::setgroups(&[gid]) {
eprintln!("fuselage: setgroups failed: {e}");
std::process::exit(1);
}
if let Err(e) = nix::unistd::setresgid(gid, gid, gid) {
eprintln!("fuselage: setresgid failed: {e}");
std::process::exit(1);
}
if let Err(e) = nix::unistd::setresuid(uid, uid, uid) {
eprintln!("fuselage: setresuid failed: {e}");
std::process::exit(1);
}
}
let err = nix::unistd::execvp(prog, argv).unwrap_err();
eprintln!("fuselage: exec {:?}: {}", prog, err);
std::process::exit(127);
}
ForkResult::Parent { child } => {
let status = waitpid(child, None).context("waitpid failed")?;
procdir::cleanup_procdir(procdir);
procdir::spawn_cache_reaper(cache_dir);
match status {
WaitStatus::Exited(_, code) => std::process::exit(code),
WaitStatus::Signaled(_, sig, _) => {
let _ = nix::sys::signal::raise(sig);
std::process::exit(128 + sig as i32);
}
_ => std::process::exit(1),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_file(dir: &std::path::Path, name: &str) -> String {
let p = dir.join(name);
fs::write(&p, b"PK\x03\x04").unwrap(); p.to_string_lossy().into_owned()
}
#[test]
fn parse_specs_empty_input() {
let mut seen = Vec::new();
let specs = parse_archive_specs(&[], &mut seen).unwrap();
assert!(specs.is_empty());
assert!(seen.is_empty());
}
#[test]
fn parse_specs_single_entry() {
let dir = tempfile::TempDir::new().unwrap();
let path = tmp_file(dir.path(), "data.zip");
let mut seen = Vec::new();
let specs = parse_archive_specs(&[path], &mut seen).unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].name, "data");
assert_eq!(seen, vec!["data"]);
}
#[test]
fn parse_specs_duplicate_in_same_list() {
let dir = tempfile::TempDir::new().unwrap();
let p1 = tmp_file(dir.path(), "data.zip");
let p2 = tmp_file(dir.path(), "other.zip");
let mut seen = Vec::new();
let result = parse_archive_specs(&[format!("data:{p1}"), format!("data:{p2}")], &mut seen);
assert!(result.is_err(), "duplicate name should be rejected");
}
#[test]
fn parse_specs_duplicate_across_two_calls() {
let dir = tempfile::TempDir::new().unwrap();
let p1 = tmp_file(dir.path(), "data.zip");
let p2 = tmp_file(dir.path(), "other.zip");
let mut seen = Vec::new();
parse_archive_specs(&[format!("shared:{p1}")], &mut seen).unwrap();
let result = parse_archive_specs(&[format!("shared:{p2}")], &mut seen);
assert!(
result.is_err(),
"duplicate name across dynamic/static should be rejected"
);
}
#[test]
fn parse_specs_two_distinct_names() {
let dir = tempfile::TempDir::new().unwrap();
let p1 = tmp_file(dir.path(), "alpha.zip");
let p2 = tmp_file(dir.path(), "beta.zip");
let mut seen = Vec::new();
let specs = parse_archive_specs(&[p1, p2], &mut seen).unwrap();
assert_eq!(specs.len(), 2);
assert_eq!(seen, vec!["alpha", "beta"]);
}
}