Merge branch 'v3' into v3-t90

This commit is contained in:
2026-11-12 09:34:42 +00:00
11 changed files with 197 additions and 84 deletions
+7 -2
View File
@@ -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());
+7 -2
View File
@@ -29,9 +29,14 @@ It also owns a registry and kache cache.
These directories live under `<dir>/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.
+64 -44
View File
@@ -47,15 +47,23 @@ pub struct Builder {
root: Arc<dyn Layer>,
directory: PathBuf,
lock: Mutex<()>,
no_sandbox: bool,
}
impl Builder {
pub fn new(root: Arc<dyn Layer>, host: &Path) -> Result<Self> {
Self::new_with_options(root, host, false)
}
pub fn new_with_options(root: Arc<dyn Layer>, host: &Path, no_sandbox: bool) -> Result<Self> {
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<Event>) -> 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 [&registry, &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 [&registry, &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(&registry)
.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!(
+11
View File
@@ -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()?;
+16 -3
View File
@@ -21,7 +21,12 @@ enum Command {
userland: Option<PathBuf>,
},
/// 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,
+6 -3
View File
@@ -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 });
+38 -9
View File
@@ -154,20 +154,36 @@ struct Started {
output: Option<DuplexStream>,
error: Option<DuplexStream>,
}
/// 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<Arc<Self>> {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
Self::with_compiler(world, cache::shared()?)
Self::new_with_options(world, RuntimeOptions::default())
}
pub fn new_with_options(world: World, options: RuntimeOptions) -> anyhow::Result<Arc<Self>> {
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<Arc<Self>> {
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<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
fn with_compiler(
world: World,
compiler: Arc<cache::Compiler>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
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<dyn crate::cred::script::Script>,
router: Option<Router>,
) -> anyhow::Result<Arc<Self>> {
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<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<Router>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
Self::with_hooks(world, cache::shared()?, cred, script, router, options)
}
fn with_hooks(
world: World,
@@ -184,6 +209,7 @@ impl Runtime {
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<http::Router>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
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));