diff --git a/docs/RUNNING.md b/docs/RUNNING.md index 55a8d24..6d2619b 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -10,6 +10,7 @@ Keep the complete world folder between starts. Run host commands from a checkout of the `v3` branch on Citadel. Use Nix with flakes. The package supplies the native Rust builder and its tools. +Use `sausage run --no-builder-sandbox WORLD` only when the host must run the builder without namespace isolation. The package entry point is: diff --git a/host/src/api/server.rs b/host/src/api/server.rs index 9036687..52a22b0 100644 --- a/host/src/api/server.rs +++ b/host/src/api/server.rs @@ -4,7 +4,11 @@ use crate::cred::Cred; use std::{path::Path, sync::Arc}; /// Preserve the console lifecycle with one API for guests and host pages. -pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> { +pub async fn run_with_userland( + path: &Path, + userland: Option<&Path>, + options: crate::process::RuntimeOptions, +) -> anyhow::Result<()> { let world = crate::world::World::open_with_userland(path, userland)?; let cred = Arc::new(Cred::open(&world.host_dir)?); let api = Arc::new(Api::open( @@ -13,7 +17,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow:: "localhost", "http://localhost:8080", )?); - let runtime = crate::process::Runtime::new_with_hooks( + let runtime = crate::process::Runtime::new_with_hooks_and_options( crate::world::World { root: world.root.clone(), host_dir: world.host_dir.clone(), @@ -22,6 +26,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow:: cred.clone(), crate::process::default_script(), Some(api.clone()), + options, )?; let (sender, receiver) = crate::harness::input::channel(); api.attach_harness(sender.clone()); diff --git a/host/src/builder/README.md b/host/src/builder/README.md index 4353501..ffbd9f1 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -29,9 +29,14 @@ It also owns a registry and kache cache. These directories live under `/host/builds/`. Cargo fetches dependencies with network access. -The command then runs offline inside bubblewrap namespaces. +By default, the command then runs offline inside bubblewrap namespaces. +The host-only `sausage run --no-builder-sandbox WORLD` switch runs Cargo directly instead. +Tests use `RuntimeOptions.no_builder_sandbox` and select it with `SAUSAGE_TEST_NO_BUILDER_SANDBOX=1`. +The switch is not a world config setting. +Direct mode uses no user, mount, network, or PID namespaces. +It retains the source copy, working directory, private registry, kache settings, timeout, output, and artifact publication. -The source copy and target directory permit writes. +In the default sandboxed mode, the source copy and target directory permit writes. The private kache cache also permits writes. The registry and Nix store remain read-only. Host home directories and the host network are absent. diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index 81b6e35..97866f2 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -47,15 +47,23 @@ pub struct Builder { root: Arc, directory: PathBuf, lock: Mutex<()>, + no_sandbox: bool, } impl Builder { pub fn new(root: Arc, host: &Path) -> Result { + Self::new_with_options(root, host, false) + } + pub fn new_with_options(root: Arc, host: &Path, no_sandbox: bool) -> Result { Ok(Self { root, directory: std::fs::canonicalize(host)?.join("builds"), lock: Mutex::new(()), + no_sandbox, }) } + fn command(&self) -> Command { + Command::new(if self.no_sandbox { "cargo" } else { "bwrap" }) + } pub async fn execute(&self, request: Request, tx: &mpsc::Sender) -> Result<()> { let _guard = self.lock.lock().await; let action = request @@ -221,60 +229,72 @@ impl Builder { if action == "run" && run_path.exists() { std::fs::remove_file(&run_path)?; } - let mut cmd = Command::new("bwrap"); - cmd.args([ - "--unshare-user", - "--unshare-pid", - "--unshare-net", - "--die-with-parent", - "--ro-bind", - "/nix/store", - "/nix/store", - ]) - .arg("--bind") - .arg(&temporary) - .arg("/tmp") - .arg("--ro-bind") - .arg(executable("sh")?) - .arg("/bin/sh"); - for path in [&source, &target_mount, &cache] { - cmd.arg("--bind").arg(path).arg(path); + let mut cmd = self.command(); + if !self.no_sandbox { + cmd.args([ + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--die-with-parent", + "--ro-bind", + "/nix/store", + "/nix/store", + ]) + .arg("--bind") + .arg(&temporary) + .arg("/tmp") + .arg("--ro-bind") + .arg(executable("sh")?) + .arg("/bin/sh"); + for path in [&source, &target_mount, &cache] { + cmd.arg("--bind").arg(path).arg(path); + } + for path in [®istry, &tools] { + cmd.arg("--ro-bind").arg(path).arg(path); + } + cmd.args([ + "--proc", + "/proc", + "--dev", + "/dev", + "--remount-ro", + "/", + "--chdir", + ]) + .arg(&cwd) + .arg("--clearenv"); + } else { + cmd.current_dir(&cwd); } - for path in [®istry, &tools] { - cmd.arg("--ro-bind").arg(path).arg(path); - } - cmd.args([ - "--proc", - "/proc", - "--dev", - "/dev", - "--remount-ro", - "/", - "--chdir", - ]) - .arg(&cwd) - .args(["--clearenv", "--setenv", "PATH"]) - .arg(std::env::var("PATH")?) - .args(["--setenv", "HOME", "/nonexistent", "--setenv", "CARGO_HOME"]) - .arg(®istry) - .args(["--setenv", "CARGO_TARGET_DIR"]) - .arg(&target) - .args(["--setenv", "KACHE_CACHE_DIR"]) - .arg(&cache) - .args(["--setenv", "SAUSAGE_CWD_INIT"]) - .arg(tools.join("init.o")) - .args(["--setenv", "SAUSAGE_RUN_PATH"]) - .arg(&run_path); + let mut environment = vec![ + ("PATH", std::env::var_os("PATH").context("PATH missing")?), + ("HOME", "/nonexistent".into()), + ("CARGO_HOME", registry.clone().into_os_string()), + ("CARGO_TARGET_DIR", target.clone().into_os_string()), + ("KACHE_CACHE_DIR", cache.clone().into_os_string()), + ("SAUSAGE_CWD_INIT", tools.join("init.o").into_os_string()), + ("SAUSAGE_RUN_PATH", run_path.clone().into_os_string()), + ]; for name in [ "CC_wasm32_wasip2", "AR_wasm32_wasip2", "CFLAGS_wasm32_wasip2", ] { if let Ok(value) = std::env::var(name) { + environment.push((name, value.into())); + } + } + for (name, value) in environment { + if self.no_sandbox { + cmd.env(name, value); + } else { cmd.args(["--setenv", name]).arg(value); } } - cmd.args(["cargo", "--offline", "--config"]) + if !self.no_sandbox { + cmd.arg("cargo"); + } + cmd.args(["--offline", "--config"]) .arg(format!("build.rustc-wrapper={:?}", kache)) .arg("--config") .arg(format!( diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs index 38dad64..c9cddf8 100644 --- a/host/src/builder/tests.rs +++ b/host/src/builder/tests.rs @@ -1,5 +1,16 @@ //! Focused tests for source discovery, argument forwarding, eviction, and process cancellation. use super::*; +#[test] +fn native_command_defaults_to_bwrap_and_can_bypass_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + assert!(!crate::process::RuntimeOptions::default().no_builder_sandbox); + let default = Builder::new(world.root.clone(), &world.host_dir)?; + assert_eq!(default.command().as_std().get_program(), "bwrap"); + let direct = Builder::new_with_options(world.root, &world.host_dir, true)?; + assert_eq!(direct.command().as_std().get_program(), "cargo"); + Ok(()) +} #[tokio::test] async fn source_links_survive_round_trip() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/host/src/main.rs b/host/src/main.rs index e95df79..da38d88 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -21,7 +21,12 @@ enum Command { userland: Option, }, /// Start a world: the harness, its services and a console. - Run { world: PathBuf }, + Run { + world: PathBuf, + /// Run the native Rust builder without namespace isolation. + #[arg(long)] + no_builder_sandbox: bool, + }, /// Run a shell in a world. With `-c`, run one command and exit with its status. Sh { world: PathBuf, @@ -79,14 +84,22 @@ async fn main() -> anyhow::Result<()> { ); Ok(()) } - Command::Run { world } => { + Command::Run { + world, + no_builder_sandbox, + } => { anyhow::ensure!( world.join("world").is_dir(), "No world at {}. Run sausage init {} first.", world.display(), world.display() ); - sausage::api::run_with_userland(&world, None).await + sausage::api::run_with_userland( + &world, + None, + sausage::process::RuntimeOptions { no_builder_sandbox }, + ) + .await } Command::Sh { world, diff --git a/host/src/process/cache.rs b/host/src/process/cache.rs index 315cf5d..4a2b8f7 100644 --- a/host/src/process/cache.rs +++ b/host/src/process/cache.rs @@ -289,7 +289,7 @@ mod tests { let _ = started_tx.send(()); let _ = finish_rx.recv(); })); - let runtime = super::super::Runtime::with_compiler(world, compiler)?; + let runtime = super::super::Runtime::with_compiler(world, compiler, Default::default())?; started_rx.await?; let command_runtime = runtime.clone(); let mut command = tokio::spawn(async move { @@ -353,7 +353,8 @@ mod tests { wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?, )?; compiler.get(parent).await?; - let runtime = super::super::Runtime::with_compiler(world, compiler.clone())?; + let runtime = + super::super::Runtime::with_compiler(world, compiler.clone(), Default::default())?; let deadline = Deadline::new(Duration::ZERO); let request = |name: &str| Request { program: name.into(), @@ -471,7 +472,9 @@ mod tests { finish_rx.recv().unwrap(); })); // Startup returns while the compiler remains blocked behind the test gate. - let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap(); + let runtime = + super::super::Runtime::with_compiler(world, compiler.clone(), Default::default()) + .unwrap(); started_rx.await.unwrap(); let background = compiler.clone(); let precompile = tokio::spawn(async move { background.get(bytes).await }); diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 1d7339a..656bda4 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -154,20 +154,36 @@ struct Started { output: Option, error: Option, } +/// Host-only switches for native builder execution. Defaults retain namespace isolation. +#[derive(Clone, Copy, Debug, Default)] +pub struct RuntimeOptions { + pub no_builder_sandbox: bool, +} impl Runtime { pub fn open(path: &Path) -> anyhow::Result> { Self::new(World::open(path)?) } pub fn new(world: World) -> anyhow::Result> { - Self::with_compiler(world, cache::shared()?) + Self::new_with_options(world, RuntimeOptions::default()) + } + pub fn new_with_options(world: World, options: RuntimeOptions) -> anyhow::Result> { + Self::with_compiler(world, cache::shared()?, options) } /// Open an independent engine using an explicit shared disk cache. pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result> { - Self::with_compiler(world, cache::Compiler::new(directory)?) + Self::with_compiler( + world, + cache::Compiler::new(directory)?, + RuntimeOptions::default(), + ) } - fn with_compiler(world: World, compiler: Arc) -> anyhow::Result> { + fn with_compiler( + world: World, + compiler: Arc, + options: RuntimeOptions, + ) -> anyhow::Result> { let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?); - Self::with_hooks(world, compiler, cred, default_script(), None) + Self::with_hooks(world, compiler, cred, default_script(), None, options) } /// Share credentials and the host API router with the host's other subsystems. pub fn new_with_hooks( @@ -176,7 +192,16 @@ impl Runtime { script: Arc, router: Option, ) -> anyhow::Result> { - Self::with_hooks(world, cache::shared()?, cred, script, router) + Self::new_with_hooks_and_options(world, cred, script, router, RuntimeOptions::default()) + } + pub fn new_with_hooks_and_options( + world: World, + cred: Arc, + script: Arc, + router: Option, + options: RuntimeOptions, + ) -> anyhow::Result> { + Self::with_hooks(world, cache::shared()?, cred, script, router, options) } fn with_hooks( world: World, @@ -184,6 +209,7 @@ impl Runtime { cred: Arc, script: Arc, router: Option, + options: RuntimeOptions, ) -> anyhow::Result> { let router = match router { Some(api) => api, @@ -195,10 +221,13 @@ impl Runtime { )?), }; router.attach_journal(world.root.journal()); - let _ = router.builder.set(Arc::new(crate::builder::Builder::new( - world.root.clone(), - &world.host_dir, - )?)); + let _ = router + .builder + .set(Arc::new(crate::builder::Builder::new_with_options( + world.root.clone(), + &world.host_dir, + options.no_builder_sandbox, + )?)); let network = Arc::new(net::NetPolicy::open(&world.host_dir)?); let engine = compiler.engine.clone(); let alive = Arc::new(AtomicBool::new(true)); diff --git a/host/tests/builder-fixture/build.rs b/host/tests/builder-fixture/build.rs index 1d1f519..c087c92 100644 --- a/host/tests/builder-fixture/build.rs +++ b/host/tests/builder-fixture/build.rs @@ -1,19 +1,24 @@ //! Assert the native build script's namespace boundaries. fn main() { - assert!(!std::path::Path::new("/home/surma/.ssh").exists()); - assert!( - !std::path::Path::new(&std::env::var("HOME").unwrap()) - .join(".ssh") - .exists() - ); - assert!(std::fs::write("/outside-probe", b"no").is_err()); - assert!( - std::net::TcpStream::connect_timeout( - &"1.1.1.1:443".parse().unwrap(), - std::time::Duration::from_millis(100) - ) - .is_err() - ); + if std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1") { + println!("cargo:warning=namespace assertions skipped: builder sandbox disabled"); + } else { + assert!(!std::path::Path::new("/home/surma/.ssh").exists()); + assert!( + !std::path::Path::new(&std::env::var("HOME").unwrap()) + .join(".ssh") + .exists() + ); + assert!(std::fs::write("/outside-probe", b"no").is_err()); + assert!( + std::net::TcpStream::connect_timeout( + &"1.1.1.1:443".parse().unwrap(), + std::time::Duration::from_millis(100) + ) + .is_err() + ); + println!("cargo:warning=namespace assertions passed"); + } let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); let object = out.join("add.o"); assert!( @@ -35,5 +40,4 @@ fn main() { ); println!("cargo:rustc-link-search=native={}", out.display()); println!("cargo:rustc-link-lib=static=add"); - println!("cargo:warning=namespace assertions passed"); } diff --git a/host/tests/it/builder.rs b/host/tests/it/builder.rs index 83f5568..6668657 100644 --- a/host/tests/it/builder.rs +++ b/host/tests/it/builder.rs @@ -16,7 +16,14 @@ fn programs() -> PathBuf { crate::common::cwd_init(); assert!( std::process::Command::new("cargo") - .args(["build", "--release", "-p", "world-cargo"]) + .args([ + "build", + "--release", + "--target", + "wasm32-wasip2", + "-p", + "world-cargo" + ]) .current_dir(root.join("userland")) .status() .unwrap() @@ -63,7 +70,7 @@ async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> { std::fs::create_dir_all(&bin)?; std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?; - let runtime = Runtime::new(world)?; + let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?; for (script, cwd, expected, success) in [ ( "cargo new hello && cd hello && cargo run", @@ -201,10 +208,15 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { ] { std::fs::write(source.join(path), bytes)?; } - let runtime = Runtime::new(world)?; + let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?; let (code, out) = cargo(&runtime, &["build"]).await?; assert_eq!(code, 0, "{out}"); - assert!(out.contains("namespace assertions passed"), "{out}"); + if crate::common::runtime_options().no_builder_sandbox { + println!("SKIP namespace isolation assertions: SAUSAGE_TEST_NO_BUILDER_SANDBOX=1"); + assert!(out.contains("namespace assertions skipped"), "{out}"); + } else { + assert!(out.contains("namespace assertions passed"), "{out}"); + } assert!(out.contains("(/project)"), "{out}"); assert!(!out.contains("/host/builds/"), "{out}"); assert_eq!( @@ -349,7 +361,11 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> { source.join("build.rs"), "fn main() { std::process::Command::new(\"sh\").args([\"-c\", \"echo $$ > child.pid; exec sleep 1000\"]).status().unwrap(); }", )?; - let builder = Arc::new(Builder::new(world.root.clone(), &world.host_dir)?); + let builder = Arc::new(Builder::new_with_options( + world.root.clone(), + &world.host_dir, + crate::common::runtime_options().no_builder_sandbox, + )?); let (tx, mut rx) = tokio::sync::mpsc::channel(16); let task = tokio::spawn(async move { builder @@ -375,7 +391,7 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> { let bin = temp.path().join("world/bin"); std::fs::create_dir_all(&bin)?; std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; - let runtime = Runtime::new(world)?; + let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?; let error = runtime .exec( Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]), diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs index 3d2b122..e768549 100644 --- a/host/tests/it/common.rs +++ b/host/tests/it/common.rs @@ -8,6 +8,12 @@ use std::{ }; pub static BUILD: Mutex<()> = Mutex::new(()); +pub fn runtime_options() -> sausage::process::RuntimeOptions { + sausage::process::RuntimeOptions { + no_builder_sandbox: std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1"), + } +} + /// Keep the real shared prompt in every roster fixture, with an ephemeral approval port. pub fn write_config( dir: &Path,