diff --git a/Cargo.lock b/Cargo.lock index 692e774..c2547d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5035,6 +5035,7 @@ dependencies = [ "hyper", "hyper-util", "if-addrs", + "libc", "openssl", "p256", "rsa", diff --git a/flake.lock b/flake.lock index b6986fb..2a693b1 100644 --- a/flake.lock +++ b/flake.lock @@ -33,6 +33,29 @@ "type": "github" } }, + "kache": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": "rust-overlay", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1792081631, + "narHash": "sha256-thzKe50UE6CKYYfKNrVyNdgQdAqr0bLRYOZzvWwlxW8=", + "owner": "kunobi-ninja", + "repo": "kache", + "rev": "96052b4842239bb216f6c4812054203c21550788", + "type": "github" + }, + "original": { + "owner": "kunobi-ninja", + "ref": "stable", + "repo": "kache", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1791637130, @@ -53,11 +76,33 @@ "inputs": { "crane": "crane", "flake-utils": "flake-utils", + "kache": "kache", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "rust-overlay": "rust-overlay_2" } }, "rust-overlay": { + "inputs": { + "nixpkgs": [ + "kache", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791101754, + "narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_2": { "inputs": { "nixpkgs": [ "nixpkgs" @@ -91,6 +136,27 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "kache", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784369104, + "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e6f06d3..355d88d 100644 --- a/flake.nix +++ b/flake.nix @@ -7,9 +7,13 @@ rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; crane.url = "github:ipetkov/crane"; + kache = { + url = "github:kunobi-ninja/kache/stable"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }: + outputs = { nixpkgs, flake-utils, rust-overlay, crane, kache, ... }: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: let pkgs = import nixpkgs { @@ -35,7 +39,8 @@ moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; }; - runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold); + builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools pkgs.bash pkgs.coreutils ]; + runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ builderTools ++ mold); # A WASI clang with wasi-libc, so that build scripts (the `cc` crate) # can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code # that needs sockets does not build. @@ -204,7 +209,7 @@ apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; }; devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { - packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold; + packages = [ rust pkgs.wasmtime ] ++ builderTools ++ mold; }); }); } diff --git a/host/Cargo.toml b/host/Cargo.toml index f1e3a08..6625717 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -62,6 +62,7 @@ http-body-util = "0.1.5" hyper-util = { version = "0.1.21", features = ["tokio"] } hyper = { version = "1.12.0", features = ["client", "http1", "server"] } tiny_http = "0.12" +libc = "0.2.190" [dev-dependencies] tempfile = "3" diff --git a/host/src/api/cargo.rs b/host/src/api/cargo.rs new file mode 100644 index 0000000..81f009e --- /dev/null +++ b/host/src/api/cargo.rs @@ -0,0 +1,60 @@ +//! A streaming Cargo response. Dropping the body cancels the builder's native process group. +use crate::builder::Event; +use bytes::Bytes; +use http::{Request, Response}; +use http_body_util::BodyExt; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +struct Body(tokio::sync::mpsc::Receiver); +impl http_body::Body for Body { + type Data = Bytes; + type Error = wasmtime_wasi_http::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.0.poll_recv(cx).map(|event| { + event.map(|event| { + let mut bytes = serde_json::to_vec(&event).expect("Cargo event"); + bytes.push(b'\n'); + Ok(http_body::Frame::data(Bytes::from(bytes))) + }) + }) + } +} +pub(crate) fn stream( + api: &super::Api, + request: Request, +) -> Response { + let (tx, rx) = tokio::sync::mpsc::channel(16); + let builder = api.builder.get().cloned(); + let request = serde_json::from_slice::(request.body()); + tokio::spawn(async move { + let result = async { + builder + .ok_or_else(|| anyhow::anyhow!("builder unavailable"))? + .execute(request?, &tx) + .await + } + .await; + if let Err(error) = result { + let _ = tx + .send(Event::Stderr { + bytes: format!("cargo: {error:#}\n").into_bytes(), + }) + .await; + let _ = tx + .send(Event::Exit { + code: 1, + executables: Vec::new(), + }) + .await; + } + }); + Response::builder() + .header("content-type", "application/x-ndjson") + .body(Body(rx).boxed_unsync()) + .unwrap() +} diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs index 2585c86..71a5225 100644 --- a/host/src/api/mod.rs +++ b/host/src/api/mod.rs @@ -1,6 +1,8 @@ //! JSON routes for host.sausage. The transport supplies the trusted caller. //! Host-owned pages use passkeys and typed proofs for every authorization ceremony. mod auth; +mod cargo; +pub(crate) use cargo::stream; mod pages; mod server; mod ssh; @@ -20,6 +22,7 @@ use std::{ pub struct Api { pub cred: Arc, + pub(crate) builder: std::sync::OnceLock>, script: cred::quickjs::QuickJs, pub passkeys: Mutex>, pub entry: EntryKey, @@ -49,6 +52,7 @@ impl Api { ) -> Self { Self { cred: cred.into(), + builder: Default::default(), script: cred::quickjs::QuickJs::default(), passkeys: Mutex::new(passkeys), entry, diff --git a/host/src/builder/README.md b/host/src/builder/README.md new file mode 100644 index 0000000..0056cec --- /dev/null +++ b/host/src/builder/README.md @@ -0,0 +1,68 @@ +# Rust builder + +The world `cargo` calls `POST http://host.sausage/cargo` with `argv` and `cwd`. +The request also accepts optional `timeout_secs`. +The response contains newline-delimited JSON events. +`stdout` and `stderr` events contain byte arrays. +The final `exit` event contains a code and component locations. +Each location contains an executable path and working directory. + +The CLI forwards the bytes immediately. +For `test` and `run`, it executes the artifacts through the process interface. +Cargo selects run binaries through a capture runner, so `--bin` and `default-run` retain their normal behavior. +The test runner uses each package's root directory, even when the caller runs Cargo from a subdirectory. +For `test` and `run`, arguments after `--` belong to the executable, not the host build. +Other commands retain their `--` arguments, such as `fmt -- --check` and Clippy lint options. + +The host finds the Cargo workspace above the requested directory and copies its sources through the shared VFS. +It excludes `target` and `.git`. + +Each workspace owns a source copy and target directory. +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. + +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. +The PID namespace removes compiler descendants and the private kache daemon after Cargo exits. + +The builder copies changed and new source files back through the VFS. +It preserves other source files, including concurrent edits in the world. +It publishes WASI executables into the world's workspace target directory. +After `cargo clean`, it removes the published artifacts that Cargo removed from the native target. +Every target build uses `wasm32-wasip2` and links the shared cwd-init object, including its numeric exit-code interface. +The host rejects other build targets and `--target-dir` overrides. + +The command deadline defaults to 30 minutes. +The host kills timed-out commands before it sends their timeout message. +A closed response cancels the native command and its process group. + +The host retains at most 20 GiB of build directories and evicts the oldest workspace first. + +The Nix wrapper supplies these tools on PATH: + +- Rust and the native compiler +- The WASI C tools +- Bubblewrap +- Nushell +- wasm-tools +- Kache +- Bash and Coreutils + +Kache comes from the upstream flake at revision `96052b4842239bb216f6c4812054203c21550788`. +The development shell supplies the same builder tools. +The builder resolves the wrapper and interpreter paths to their canonical Nix store paths. +The embedded cwd-init sources produce a startup object and a linker script inside each workspace's tool folder. +The linker script uses the canonical Nushell interpreter, so it needs no host `/usr/bin/env`. +The sandbox exposes Bash at `/bin/sh` for native build scripts. +It does not depend on a user-profile path or a host HOME file. +Development commands retain the global Cargo configuration and its kache wrapper. +The sandbox uses a private Cargo home and selects kache through `--config build.rustc-wrapper`. + +For `cargo add`, the network phase performs a dry run to populate the registry index. +The offline command makes the actual manifest change inside the namespaces. +The disk cap applies after each completed command, including compiler failures. diff --git a/host/src/builder/files.rs b/host/src/builder/files.rs new file mode 100644 index 0000000..7e3a2ef --- /dev/null +++ b/host/src/builder/files.rs @@ -0,0 +1,156 @@ +//! Workspace discovery and source/artifact copies through the world's shared VFS. +use anyhow::{Context, Result}; +use sausage_vfs::{Layer, Open}; +use std::{collections::HashMap, path::Path}; + +/// Source hashes before the native command changes its copy. +pub type Snapshot = HashMap; + +pub async fn read(root: &dyn Layer, path: &str) -> Result> { + let file = root + .open( + path, + Open { + follow: true, + ..Default::default() + }, + ) + .await?; + let size = file.stat().await?.size; + Ok(file.read(0, usize::try_from(size)?).await?) +} +pub async fn workspace(root: &dyn Layer, cwd: &str) -> Result { + let mut path = sausage_vfs::resolve("/", cwd); + let mut nearest = None; + loop { + let manifest = sausage_vfs::resolve(&path, "Cargo.toml"); + if let Ok(bytes) = read(root, &manifest).await { + let value: toml::Value = toml::from_str(std::str::from_utf8(&bytes)?)?; + if value.get("workspace").is_some() { + return Ok(path); + } + if nearest.is_none() { + nearest = Some(path.clone()); + } + } + if path == "/" { + break; + } + path = sausage_vfs::resolve(&path, ".."); + } + nearest.context("no Cargo.toml above cwd") +} +pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result { + std::fs::create_dir_all(destination)?; + let mut before = Snapshot::new(); + for entry in root.list(source).await? { + if matches!(entry.name.as_str(), "target" | ".git") { + continue; + } + let from = sausage_vfs::resolve(source, &entry.name); + let to = destination.join(&entry.name); + if entry.directory { + before.extend(Box::pin(copy_out(root, &from, &to)).await?); + } else { + let bytes = read(root, &from).await?; + before.insert(from, openssl::sha::sha256(&bytes)); + std::fs::write(to, bytes)?; + } + } + Ok(before) +} +pub async fn mkdir(root: &dyn Layer, path: &str) -> Result<()> { + let mut current = String::new(); + for part in path.split('/').filter(|p| !p.is_empty()) { + current.push('/'); + current.push_str(part); + if let Err(error) = root.mkdir(¤t).await + && error.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(error.into()); + } + } + Ok(()) +} +pub async fn write(root: &dyn Layer, path: &str, bytes: Vec) -> Result<()> { + if read(root, path).await.ok().as_deref() == Some(bytes.as_slice()) { + return Ok(()); + } + mkdir(root, path.rsplit_once('/').unwrap().0).await?; + let file = root + .open( + path, + Open { + follow: true, + write: true, + create: true, + truncate: true, + ..Default::default() + }, + ) + .await?; + let mut offset = 0; + while offset < bytes.len() { + let written = file.write(offset as u64, bytes[offset..].to_vec()).await?; + anyhow::ensure!(written != 0, "source write made no progress"); + offset += written; + } + Ok(()) +} +/// A snapshot selects changed sources. Without a snapshot, publish only WASM artifacts. +pub async fn copy_back( + root: &dyn Layer, + source: &Path, + destination: &str, + before: Option<&Snapshot>, +) -> Result<()> { + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if matches!(name.as_str(), "target" | ".git") { + continue; + } + let to = sausage_vfs::resolve(destination, &name); + if entry.file_type()?.is_dir() { + Box::pin(copy_back(root, &entry.path(), &to, before)).await?; + } else if before.is_some() || entry.path().extension().is_some_and(|e| e == "wasm") { + let bytes = std::fs::read(entry.path())?; + if before.is_none_or(|before| before.get(&to) != Some(&openssl::sha::sha256(&bytes))) { + write(root, &to, bytes).await?; + } + } + } + Ok(()) +} + +/// Remove published artifacts that Cargo clean removed from the native target. +pub async fn clean_artifacts(root: &dyn Layer, native: &Path, world: &str) -> Result<()> { + let entries = match root.list(world).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let path = sausage_vfs::resolve(world, &entry.name); + let native = native.join(&entry.name); + if entry.directory { + Box::pin(clean_artifacts(root, &native, &path)).await?; + } else if entry.name.ends_with(".wasm") && !native.exists() { + root.remove_file(&path).await?; + } + } + Ok(()) +} + +pub fn size(path: &Path) -> Result { + let mut total = 0; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + total += if entry.file_type()?.is_dir() { + size(&entry.path())? + } else { + entry.metadata()?.len() + }; + } + Ok(total) +} diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs new file mode 100644 index 0000000..b1c17e2 --- /dev/null +++ b/host/src/builder/mod.rs @@ -0,0 +1,492 @@ +//! Native Cargo execution, isolated workspace copies, streamed output, and bounded disk use. +mod files; +#[cfg(test)] +mod tests; +use anyhow::{Context, Result, ensure}; +use sausage_vfs::Layer; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::AsyncReadExt, + process::Command, + sync::{Mutex, mpsc}, + time::Instant, +}; + +#[derive(Deserialize)] +pub struct Request { + pub argv: Vec, + pub cwd: String, + pub timeout_secs: Option, +} +#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Executable { + pub path: String, + pub cwd: String, +} +#[derive(Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Stdout { + bytes: Vec, + }, + Stderr { + bytes: Vec, + }, + Exit { + code: i32, + executables: Vec, + }, +} +pub struct Builder { + root: Arc, + directory: PathBuf, + lock: Mutex<()>, +} +impl Builder { + pub fn new(root: Arc, host: &Path) -> Result { + Ok(Self { + root, + directory: std::fs::canonicalize(host)?.join("builds"), + lock: Mutex::new(()), + }) + } + pub async fn execute(&self, request: Request, tx: &mpsc::Sender) -> Result<()> { + let _guard = self.lock.lock().await; + let action = request + .argv + .first() + .context("missing Cargo command")? + .as_str(); + ensure!( + [ + "build", "check", "clippy", "test", "run", "fmt", "fetch", "add", "remove", + "update", "tree", "metadata", "clean" + ] + .contains(&action), + "unsupported Cargo command" + ); + let compiles = ["build", "check", "clippy", "test", "run"].contains(&action); + let mut args = request.argv.iter().take_while(|arg| arg.as_str() != "--"); + while let Some(arg) = args.next() { + ensure!( + arg != "--target-dir" && !arg.starts_with("--target-dir="), + "the builder controls the target directory" + ); + let target = if arg == "--target" { + args.next().map(String::as_str) + } else { + arg.strip_prefix("--target=") + }; + if compiles && let Some(target) = target { + ensure!( + target == "wasm32-wasip2", + "the build target is always wasm32-wasip2" + ); + } + } + let seconds = request.timeout_secs.unwrap_or(1800); + ensure!(seconds > 0, "timeout must be positive"); + let deadline = Instant::now() + .checked_add(Duration::from_secs(seconds)) + .context("timeout too large")?; + let world_cwd = sausage_vfs::resolve("/", &request.cwd); + let workspace = files::workspace(self.root.as_ref(), &world_cwd).await?; + let world_target = sausage_vfs::resolve(&workspace, "target"); + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + workspace.hash(&mut hash); + let id = format!("{:016x}", hash.finish()); + let base = self.directory.join(id); + let source = base.join("source"); + // Cargo clean must remove its directory, not a namespace mount point. + let target_mount = base.join("target"); + let target = target_mount.join("cargo"); + // Keep /tmp's namespace mountpoints outside the directory Cargo clean removes. + let temporary = target_mount.join("tmp"); + let registry = base.join("cargo"); + let cache = base.join("kache"); + if source.exists() { + std::fs::remove_dir_all(&source)?; + } + let before = files::copy_out(self.root.as_ref(), &workspace, &source).await?; + for path in [&target, ®istry, &cache, &temporary] { + std::fs::create_dir_all(path)?; + } + std::fs::write( + target.join("CACHEDIR.TAG"), + b"Signature: 8a477f597d28d172789f06886806bc55\n", + )?; + std::fs::write(base.join("last-used"), [])?; + let relative = world_cwd + .strip_prefix(&workspace) + .expect("workspace is an ancestor of cwd") + .trim_start_matches('/'); + let cwd = if relative.is_empty() { + source.clone() + } else { + source.join(relative) + }; + let mut fetch = Command::new("cargo"); + if action == "add" { + fetch.args(&request.argv).arg("--dry-run"); + } else { + fetch.arg("fetch"); + } + fetch.current_dir(&cwd).env("CARGO_HOME", ®istry); + let mut code = if matches!(action, "clean" | "fmt" | "remove") { + 0 + } else { + run(fetch, tx, deadline).await?.0 + }; + let mut executables = Vec::new(); + if code == 0 { + let tools = base.join("tools"); + std::fs::create_dir_all(&tools)?; + for (name, bytes) in [ + ( + "init.rs", + include_bytes!("../../../userland/cwd-init/init.rs").as_slice(), + ), + ( + "exit.wit", + include_bytes!("../../../userland/cwd-init/exit.wit").as_slice(), + ), + ( + "build.nu", + include_bytes!("../../../userland/cwd-init/build.nu").as_slice(), + ), + ( + "link.nu", + include_bytes!("../../../userland/cwd-init/link.nu").as_slice(), + ), + ("run.nu", include_bytes!("run.nu").as_slice()), + ] { + std::fs::write(tools.join(name), bytes)?; + } + let linker = std::fs::read_to_string(tools.join("link.nu"))?.replacen( + "#!/usr/bin/env nu", + &format!("#!{}", executable("nu")?.display()), + 1, + ); + std::fs::write(tools.join("link.nu"), linker)?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + tools.join("link.nu"), + std::fs::Permissions::from_mode(0o755), + )?; + let mut startup = Command::new("nu"); + startup.arg(tools.join("build.nu")); + let (startup_code, _) = run(startup, tx, deadline).await?; + if startup_code != 0 { + files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?; + self.evict(20 * 1024 * 1024 * 1024)?; + tx.send(Event::Exit { + code: startup_code, + executables, + }) + .await?; + return Ok(()); + } + let kache = executable("kache")?; + let run_path = target_mount.join("run-path"); + 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); + } + 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) + .args(["--setenv", "CARGO_BUILD_JOBS", "2"]); + for name in [ + "CC_wasm32_wasip2", + "AR_wasm32_wasip2", + "CFLAGS_wasm32_wasip2", + ] { + if let Ok(value) = std::env::var(name) { + cmd.args(["--setenv", name]).arg(value); + } + } + cmd.args(["cargo", "--offline", "--config"]) + .arg(format!("build.rustc-wrapper={:?}", kache)) + .arg("--config") + .arg(format!( + "target.wasm32-wasip2.linker={:?}", + tools.join("link.nu") + )) + .args([ + "--config", + "target.wasm32-wasip2.rustflags=['-C','link-arg=sausage-cwd-init']", + ]); + let mut args = request.argv.clone(); + if matches!(action, "run" | "test") + && let Some(index) = args.iter().position(|a| a == "--") + { + args.truncate(index); + } + if action == "test" { + // Cargo's positional test filter belongs to the world runner, not the compiler. + args = test_build_args(&args); + if !args.iter().any(|a| a == "--no-run") { + args.push("--no-run".into()); + } + } + let split = args.iter().position(|a| a == "--").unwrap_or(args.len()); + if action == "run" { + cmd.arg("--config").arg(format!( + "target.wasm32-wasip2.runner=[{:?},{:?}]", + executable("nu")?, + tools.join("run.nu") + )); + } + // Keep all global config flags before the subcommand so Cargo retains them. + cmd.args(&args[..split]); + if compiles { + cmd.args([ + "--target", + "wasm32-wasip2", + "--message-format=json-render-diagnostics", + ]); + } + cmd.args(&args[split..]); + let (status, stdout) = run(cmd, tx, deadline).await?; + code = status; + if action == "clean" && code == 0 { + files::clean_artifacts(self.root.as_ref(), &target, &world_target).await?; + } + let artifacts = target.join("wasm32-wasip2"); + if artifacts.exists() { + files::copy_back( + self.root.as_ref(), + &artifacts, + &format!("{world_target}/wasm32-wasip2"), + None, + ) + .await?; + } + if code == 0 && action == "run" && run_path.exists() { + let path = std::fs::read_to_string(&run_path)?; + let relative = Path::new(&path).strip_prefix(&target)?; + executables.push(Executable { + path: format!("{world_target}/{}", relative.display()), + cwd: world_cwd.clone(), + }); + } + if code == 0 + && action == "test" + && !request + .argv + .iter() + .take_while(|a| a.as_str() != "--") + .any(|a| a == "--no-run") + { + for line in stdout.split(|b| *b == b'\n') { + if let Ok(value) = serde_json::from_slice::(line) + && value["reason"] == "compiler-artifact" + && value["profile"]["test"] == true + && let Some(path) = value["executable"].as_str() + && let Ok(relative) = Path::new(path).strip_prefix(&target) + { + let manifest = Path::new( + value["manifest_path"] + .as_str() + .context("missing test manifest")?, + ); + let package = manifest.parent().unwrap().strip_prefix(&source)?; + let cwd = sausage_vfs::resolve(&workspace, &package.to_string_lossy()); + executables.push(Executable { + path: format!("{world_target}/{}", relative.display()), + cwd, + }); + } + } + executables.sort(); + executables.dedup(); + } + } + files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?; + self.evict(20 * 1024 * 1024 * 1024)?; + tx.send(Event::Exit { code, executables }).await?; + Ok(()) + } + fn evict(&self, limit: u64) -> Result<()> { + let mut entries = Vec::new(); + let mut total = 0; + for entry in std::fs::read_dir(&self.directory)? { + let path = entry?.path(); + let size = files::size(&path)?; + total += size; + entries.push(( + std::fs::metadata(path.join("last-used"))?.modified()?, + path, + size, + )); + } + entries.sort_by_key(|e| e.0); + for (_, path, size) in entries { + if total <= limit { + break; + } + std::fs::remove_dir_all(path)?; + total -= size; + } + Ok(()) + } +} +fn executable(name: &str) -> Result { + let path = std::env::split_paths(&std::env::var_os("PATH").context("PATH missing")?) + .map(|p| p.join(name)) + .find(|p| p.is_file()) + .context(format!("missing {name}"))?; + Ok(std::fs::canonicalize(path)?) +} +fn test_build_args(args: &[String]) -> Vec { + let mut out = vec!["test".into()]; + let mut value = false; + for arg in &args[1..] { + if value || arg.starts_with('-') { + out.push(arg.clone()); + } + value = [ + "-p", + "--package", + "--test", + "--bin", + "--example", + "--features", + "--profile", + "--manifest-path", + "--exclude", + "-j", + "--jobs", + "-F", + "--config", + "--target", + "--target-dir", + "--message-format", + "--color", + "--lockfile-path", + "-Z", + ] + .contains(&arg.as_str()); + } + out +} +// The guard kills native descendants when Cargo exits or the caller cancels the command. +struct ProcessGroup(u32); +impl Drop for ProcessGroup { + fn drop(&mut self) { + unsafe { + libc::kill(-(self.0 as i32), libc::SIGKILL); + } + } +} +async fn pump( + mut reader: impl tokio::io::AsyncRead + Unpin, + tx: &mpsc::Sender, + stderr: bool, +) -> Result> { + let mut all = Vec::new(); + let mut bytes = vec![0; 8192]; + loop { + let n = reader.read(&mut bytes).await?; + if n == 0 { + return Ok(all); + } + if !stderr { + all.extend_from_slice(&bytes[..n]); + } + tx.send(if stderr { + Event::Stderr { + bytes: bytes[..n].to_vec(), + } + } else { + Event::Stdout { + bytes: bytes[..n].to_vec(), + } + }) + .await?; + } +} +async fn run( + mut cmd: Command, + tx: &mpsc::Sender, + deadline: Instant, +) -> Result<(i32, Vec)> { + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0) + .kill_on_drop(true); + let mut child = cmd.spawn()?; + let group = ProcessGroup(child.id().unwrap()); + let out = child.stdout.take().unwrap(); + let err = child.stderr.take().unwrap(); + let collect = async { + let (status, out, _) = tokio::try_join!( + async { Ok::<_, anyhow::Error>(child.wait().await?) }, + pump(out, tx, false), + pump(err, tx, true) + )?; + Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out)) + }; + let (result, timed_out) = tokio::select! { + result = collect => (result, false), + _ = tx.closed() => anyhow::bail!("caller exited"), + _ = tokio::time::sleep_until(deadline) => (Ok((124, Vec::new())), true), + }; + // Kill the command before a full output channel can delay its timeout message. + drop(group); + if timed_out { + tx.send(Event::Stderr { + bytes: b"cargo: command timed out\n".to_vec(), + }) + .await?; + } + result +} diff --git a/host/src/builder/run.nu b/host/src/builder/run.nu new file mode 100644 index 0000000..2eebfa4 --- /dev/null +++ b/host/src/builder/run.nu @@ -0,0 +1,4 @@ +# Record Cargo's selected component without executing it on the host. +def main [executable: string] { + $executable | save --raw --force $env.SAUSAGE_RUN_PATH +} diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs new file mode 100644 index 0000000..c373593 --- /dev/null +++ b/host/src/builder/tests.rs @@ -0,0 +1,236 @@ +//! Focused tests for source discovery, argument forwarding, eviction, and process cancellation. +use super::*; +#[tokio::test] +async fn discovers_workspace_and_skips_target() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + files::write( + world.root.as_ref(), + "/project/Cargo.toml", + b"[workspace]\nmembers=['member']".to_vec(), + ) + .await?; + files::write( + world.root.as_ref(), + "/project/member/Cargo.toml", + b"[package]\nname='member'\nversion='0.1.0'".to_vec(), + ) + .await?; + files::write( + world.root.as_ref(), + "/project/target/junk", + b"junk".to_vec(), + ) + .await?; + assert_eq!( + files::workspace(world.root.as_ref(), "/project/member/src").await?, + "/project" + ); + let copy = temp.path().join("copy"); + files::copy_out(world.root.as_ref(), "/project", ©).await?; + assert!(copy.join("member/Cargo.toml").exists()); + assert!(!copy.join("target").exists()); + Ok(()) +} +#[tokio::test] +async fn copies_only_changed_and_new_sources() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + for name in ["unchanged.rs", "changed.rs"] { + files::write( + world.root.as_ref(), + &format!("/project/{name}"), + b"initial".to_vec(), + ) + .await?; + } + let copy = temp.path().join("copy"); + let before = files::copy_out(world.root.as_ref(), "/project", ©).await?; + files::write( + world.root.as_ref(), + "/project/unchanged.rs", + b"concurrent edit".to_vec(), + ) + .await?; + std::fs::write(copy.join("changed.rs"), b"changed by command")?; + std::fs::write(copy.join("Cargo.lock"), b"created by command")?; + files::copy_back(world.root.as_ref(), ©, "/project", Some(&before)).await?; + for (name, expected) in [ + ("unchanged.rs", "concurrent edit"), + ("changed.rs", "changed by command"), + ("Cargo.lock", "created by command"), + ] { + assert_eq!( + files::read(world.root.as_ref(), &format!("/project/{name}")).await?, + expected.as_bytes() + ); + } + Ok(()) +} +#[tokio::test] +async fn clean_removes_only_deleted_artifacts() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let native = temp.path().join("native"); + std::fs::create_dir_all(&native)?; + std::fs::write(native.join("keep.wasm"), b"keep")?; + for name in ["keep.wasm", "remove.wasm", "note.txt"] { + files::write( + world.root.as_ref(), + &format!("/target/{name}"), + b"data".to_vec(), + ) + .await?; + } + files::clean_artifacts(world.root.as_ref(), &native, "/target").await?; + assert!( + files::read(world.root.as_ref(), "/target/remove.wasm") + .await + .is_err() + ); + for name in ["keep.wasm", "note.txt"] { + assert_eq!( + files::read(world.root.as_ref(), &format!("/target/{name}")).await?, + b"data" + ); + } + Ok(()) +} +#[test] +fn relative_host_directory_uses_absolute_namespace_paths() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, Path::new("."))?; + assert!(builder.directory.is_absolute()); + Ok(()) +} +#[tokio::test] +async fn target_options_preserve_the_platform_target_and_cache() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, &world.host_dir)?; + let (tx, _rx) = mpsc::channel(1); + for options in [ + vec!["--target", "aarch64-unknown-linux-gnu"], + vec!["--target=aarch64-unknown-linux-gnu"], + vec!["--target-dir", "/other"], + vec!["--target-dir=/other"], + ] { + let error = builder + .execute( + Request { + argv: std::iter::once("build") + .chain(options) + .map(str::to_string) + .collect(), + cwd: "/".into(), + timeout_secs: None, + }, + &tx, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("target"), "{error}"); + } + for (action, options) in [ + ("run", vec!["--target", "wasm32-wasip2"]), + ("run", vec!["--target=wasm32-wasip2"]), + ("run", vec!["--", "--target-dir", "/argument"]), + ("tree", vec!["--target", "all"]), + ] { + let error = builder + .execute( + Request { + argv: std::iter::once(action) + .chain(options) + .map(str::to_string) + .collect(), + cwd: "/".into(), + timeout_secs: None, + }, + &tx, + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "no Cargo.toml above cwd"); + } + Ok(()) +} +#[test] +fn test_filter_is_not_a_build_argument() { + let args = ["test", "-p", "example", "filter", "--release"].map(str::to_string); + assert_eq!( + test_build_args(&args), + ["test", "-p", "example", "--release"] + ); +} +#[test] +fn disk_cap_removes_workspaces() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, &world.host_dir)?; + for name in ["a", "b"] { + let path = builder.directory.join(name); + std::fs::create_dir_all(&path)?; + std::fs::write(path.join("last-used"), [])?; + std::fs::write(path.join("data"), [0; 32])?; + } + std::fs::File::open(builder.directory.join("a/last-used"))? + .set_times(std::fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH))?; + builder.evict(32)?; + assert!(!builder.directory.join("a").exists()); + assert!(builder.directory.join("b").exists()); + assert_eq!(files::size(&builder.directory)?, 32); + Ok(()) +} +#[cfg(target_os = "linux")] +#[tokio::test] +async fn timeout_kills_before_output_can_drain() -> Result<()> { + use std::os::fd::{FromRawFd, OwnedFd}; + let (tx, mut rx) = mpsc::channel(1); + let output = tx.clone(); + let mut cmd = Command::new("sh"); + cmd.args(["-c", "echo $$; exec sleep 1000"]); + let task = + tokio::spawn( + async move { run(cmd, &output, Instant::now() + Duration::from_secs(1)).await }, + ); + let Some(Event::Stdout { bytes }) = + tokio::time::timeout(Duration::from_secs(2), rx.recv()).await? + else { + anyhow::bail!("missing native PID"); + }; + let pid: u32 = std::str::from_utf8(&bytes)?.trim().parse()?; + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + ensure!(fd >= 0, "pidfd_open: {}", std::io::Error::last_os_error()); + // OwnedFd keeps the PID descriptor valid and unchanged until AsyncFd drops it. + let exited = unsafe { tokio::io::unix::AsyncFd::register(OwnedFd::from_raw_fd(fd as i32)) }?; + // Fill the output channel, so the timeout message cannot enter it. + tx.send(Event::Stdout { bytes: Vec::new() }).await?; + let _ready = tokio::time::timeout(Duration::from_secs(2), exited.readable()).await??; + assert!(matches!(rx.recv().await, Some(Event::Stdout { .. }))); + assert!(matches!(rx.recv().await, Some(Event::Stderr { bytes }) + if bytes.as_slice() == b"cargo: command timed out\n")); + assert_eq!(task.await??.0, 124); + Ok(()) +} +#[tokio::test] +async fn closed_receiver_cancels_command() -> Result<()> { + let (tx, mut rx) = mpsc::channel(1); + let mut cmd = Command::new("sh"); + cmd.args(["-c", "printf ready; exec sleep 1000"]); + let task = + tokio::spawn(async move { run(cmd, &tx, Instant::now() + Duration::from_secs(60)).await }); + // Output must arrive before the command exits, not after collection completes. + let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await? + .context("missing streamed output")?; + assert!(matches!(event, Event::Stdout { bytes } if bytes == b"ready")); + drop(rx); + assert!( + tokio::time::timeout(Duration::from_secs(2), task) + .await?? + .is_err() + ); + Ok(()) +} diff --git a/host/src/lib.rs b/host/src/lib.rs index 048b719..9ad732b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -2,6 +2,7 @@ //! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here. pub mod api; +pub mod builder; pub mod cred; pub mod exec; pub mod harness; diff --git a/host/src/process/http.rs b/host/src/process/http.rs index 7db1772..8772a84 100644 --- a/host/src/process/http.rs +++ b/host/src/process/http.rs @@ -45,9 +45,14 @@ impl WasiHttpHooks for Hooks { .is_some_and(|h| h.eq_ignore_ascii_case("host.sausage")) { let bytes = body.collect().await?.to_bytes(); - return Ok(response( - crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await, - )); + let request = Request::from_parts(parts, bytes); + if request.method() == http::Method::POST && request.uri().path() == "/cargo" { + return Ok(( + crate::api::stream(&router, request), + Box::new(async { Ok(()) }) as Io, + )); + } + return Ok(response(crate::api::handle(&router, caller, request).await)); } if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { while let Some(frame) = body.frame().await { diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index c305c6d..8c1e850 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -193,6 +193,10 @@ 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 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/Cargo.toml b/host/tests/builder-fixture/Cargo.toml new file mode 100644 index 0000000..98f63a4 --- /dev/null +++ b/host/tests/builder-fixture/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "builder-fixture" +version = "0.1.0" +edition = "2024" + +[workspace] + +[dependencies] +serde = "1" diff --git a/host/tests/builder-fixture/build.rs b/host/tests/builder-fixture/build.rs new file mode 100644 index 0000000..1d1f519 --- /dev/null +++ b/host/tests/builder-fixture/build.rs @@ -0,0 +1,39 @@ +//! 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() + ); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + let object = out.join("add.o"); + assert!( + std::process::Command::new(std::env::var("CC_wasm32_wasip2").unwrap()) + .args(["-c", "src/add.c", "-o"]) + .arg(&object) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new(std::env::var("AR_wasm32_wasip2").unwrap()) + .arg("crs") + .arg(out.join("libadd.a")) + .arg(&object) + .status() + .unwrap() + .success() + ); + 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/builder-fixture/src/add.c b/host/tests/builder-fixture/src/add.c new file mode 100644 index 0000000..f83c3dc --- /dev/null +++ b/host/tests/builder-fixture/src/add.c @@ -0,0 +1 @@ +int c_add(int a, int b) { return a + b; } diff --git a/host/tests/builder-fixture/src/main.rs b/host/tests/builder-fixture/src/main.rs new file mode 100644 index 0000000..891f1b6 --- /dev/null +++ b/host/tests/builder-fixture/src/main.rs @@ -0,0 +1,22 @@ +//! An executable and tests that run only inside the world. +unsafe extern "C" { + fn c_add(a: i32, b: i32) -> i32; +} +fn main() { + if std::env::args().any(|arg| arg == "--exit") { + std::process::exit(23); + } + assert_eq!(unsafe { c_add(20, 22) }, 42); + println!( + "builder fixture cwd: {}", + std::env::current_dir().unwrap().display() + ); +} +#[test] +fn pass() { + assert_eq!(std::env::current_dir().unwrap().to_str(), Some("/project")); +} +#[test] +fn fail() { + panic!("intentional fixture failure"); +} diff --git a/host/tests/builder-fixture/tests/integration.rs b/host/tests/builder-fixture/tests/integration.rs new file mode 100644 index 0000000..6a880cb --- /dev/null +++ b/host/tests/builder-fixture/tests/integration.rs @@ -0,0 +1,10 @@ +//! Cargo executes each integration test component through the world process interface. +#[test] +fn pass_integration() { + assert!( + std::fs::read_to_string("Cargo.toml") + .unwrap() + .contains("builder-fixture") + ); + println!("INTEGRATION_CASE"); +} diff --git a/host/tests/builder.rs b/host/tests/builder.rs new file mode 100644 index 0000000..019823a --- /dev/null +++ b/host/tests/builder.rs @@ -0,0 +1,315 @@ +//! Real-world Cargo acceptance: native namespaces, source changes, cache, and WASI execution. +use sausage::{ + exec::{Command, Exec, Options}, + process::Runtime, + world::World, +}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, OnceLock}, + time::Duration, +}; +fn programs() -> PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + assert!( + std::process::Command::new("nu") + .arg(root.join("userland/cwd-init/build.nu")) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new("cargo") + .args(["build", "--release", "-p", "world-cargo"]) + .current_dir(root.join("userland")) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new("nu") + .arg(root.join("userland/brush/build.nu")) + .status() + .unwrap() + .success() + ); + PathBuf::from(std::env::var_os("CARGO_TARGET_DIR").expect("executor target required")) + .join("wasm32-wasip2/release") + }) + .clone() +} +async fn cargo(runtime: &Arc, args: &[&str]) -> anyhow::Result<(i32, String)> { + cargo_at(runtime, args, "/project").await +} +async fn cargo_at( + runtime: &Arc, + args: &[&str], + cwd: &str, +) -> anyhow::Result<(i32, String)> { + let mut bytes = Vec::new(); + let code = runtime + .exec( + Command::Argv( + std::iter::once("/bin/cargo.wasm".into()) + .chain(args.iter().map(|a| a.to_string())) + .collect(), + ), + Options { + cwd: cwd.into(), + timeout: Some(Duration::from_secs(120)), + ..Default::default() + }, + &mut |_, b| bytes.extend_from_slice(b), + ) + .await?; + Ok((code, String::from_utf8_lossy(&bytes).into_owned())) +} +fn cache_hits(base: &Path) -> anyhow::Result { + let stats = std::process::Command::new("kache") + .arg("stats") + .env("KACHE_CACHE_DIR", base.join("kache")) + .output()?; + assert!( + stats.status.success(), + "{}", + String::from_utf8_lossy(&stats.stderr) + ); + let text = String::from_utf8_lossy(&stats.stdout); + println!("PRIVATE CACHE: {text}"); + let words: Vec<_> = text.split_whitespace().collect(); + let hits = words.windows(2).find_map(|pair| { + (pair[1] == "hits") + .then(|| pair[0].replace(',', "").parse::().ok()) + .flatten() + }); + assert!( + std::process::Command::new("kache") + .args(["daemon", "stop"]) + .env("KACHE_CACHE_DIR", base.join("kache")) + .status()? + .success() + ); + Ok(hits.expect("kache stats must report its lifetime hits")) +} +#[tokio::test(flavor = "multi_thread")] +async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { + let binaries = programs(); + let temp = tempfile::tempdir()?; + let world = World::open(temp.path())?; + let bin = temp.path().join("world/bin"); + 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 source = temp.path().join("world/project"); + std::fs::create_dir_all(source.join("src"))?; + std::fs::create_dir_all(source.join("tests"))?; + for (path, bytes) in [ + ( + "Cargo.toml", + include_bytes!("builder-fixture/Cargo.toml").as_slice(), + ), + ( + "build.rs", + include_bytes!("builder-fixture/build.rs").as_slice(), + ), + ( + "src/main.rs", + include_bytes!("builder-fixture/src/main.rs").as_slice(), + ), + ( + "tests/integration.rs", + include_bytes!("builder-fixture/tests/integration.rs").as_slice(), + ), + ( + "src/add.c", + include_bytes!("builder-fixture/src/add.c").as_slice(), + ), + ] { + std::fs::write(source.join(path), bytes)?; + } + let runtime = Runtime::new(world)?; + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("namespace assertions passed"), "{out}"); + assert!(source.join("Cargo.lock").exists()); + let mut shell_output = Vec::new(); + let shell_code = runtime + .exec( + Command::Argv(vec![ + "/bin/sh.wasm".into(), + "-c".into(), + "./target/wasm32-wasip2/debug/builder-fixture.wasm".into(), + ]), + Options { + cwd: "/project".into(), + ..Default::default() + }, + &mut |_, b| shell_output.extend_from_slice(b), + ) + .await?; + assert_eq!(shell_code, 0, "{}", String::from_utf8_lossy(&shell_output)); + assert!(String::from_utf8_lossy(&shell_output).contains("builder fixture cwd: /project")); + let (code, out) = cargo(&runtime, &["run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("builder fixture cwd: /project"), "{out}"); + let (code, out) = cargo(&runtime, &["run", "--", "--no-run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("builder fixture cwd: /project"), "{out}"); + let (code, out) = cargo(&runtime, &["run", "--", "--exit"]).await?; + assert_eq!(code, 23, "{out}"); + let (code, out) = cargo(&runtime, &["test", "pass", "--", "--nocapture"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("1 passed"), "{out}"); + assert!(out.contains("INTEGRATION_CASE"), "{out}"); + let (code, out) = cargo_at( + &runtime, + &["test", "pass", "--", "--nocapture"], + "/project/src", + ) + .await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("INTEGRATION_CASE"), "{out}"); + // WASI aborts on panic, so disable capture to expose the panic message. + let (code, out) = cargo(&runtime, &["test", "fail", "--", "--nocapture"]).await?; + assert_eq!(code, 101, "{out}"); + assert!(out.contains("intentional fixture failure"), "{out}"); + let (code, out) = cargo(&runtime, &["test", "--no-run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(!out.contains("intentional fixture failure"), "{out}"); + std::fs::create_dir_all(source.join("src/bin"))?; + std::fs::write( + source.join("src/bin/other.rs"), + "fn main() { println!(\"OTHER_BIN\"); }\n", + )?; + let (code, out) = cargo(&runtime, &["run"]).await?; + assert_eq!(code, 101, "{out}"); + assert!(out.contains("--bin"), "{out}"); + let manifest = std::fs::read_to_string(source.join("Cargo.toml"))?; + std::fs::write( + source.join("Cargo.toml"), + manifest.replace("[package]\n", "[package]\ndefault-run = \"other\"\n"), + )?; + let (code, out) = cargo(&runtime, &["run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("OTHER_BIN"), "{out}"); + let (code, out) = cargo(&runtime, &["run", "--bin", "builder-fixture"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("builder fixture cwd: /project"), "{out}"); + for action in ["check", "clippy", "fetch", "tree", "metadata", "update"] { + let (code, out) = cargo(&runtime, &[action]).await?; + assert_eq!(code, 0, "{action}: {out}"); + } + let (code, out) = cargo(&runtime, &["clippy", "--", "-D", "clippy::print_stdout"]).await?; + assert_ne!(code, 0, "{out}"); + assert!(out.contains("stdout"), "{out}"); + let base = std::fs::read_dir(temp.path().join("host/builds"))? + .next() + .unwrap()? + .path(); + let before = cache_hits(&base)?; + let (code, out) = cargo(&runtime, &["clean"]).await?; + assert_eq!(code, 0, "{out}"); + assert!( + !source + .join("target/wasm32-wasip2/debug/builder-fixture.wasm") + .exists() + ); + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_eq!(code, 0, "{out}"); + let after = cache_hits(&base)?; + println!("CACHE AFTER CLEAN: {before} -> {after} hits"); + assert!( + after > before, + "the second build did not restore cached actions" + ); + std::fs::write( + source.join("src/main.rs"), + "fn main(){println!(\"format me\");}\n", + )?; + let unformatted = std::fs::read(source.join("src/main.rs"))?; + let (code, out) = cargo(&runtime, &["fmt", "--", "--check"]).await?; + assert_ne!(code, 0, "{out}"); + assert_eq!(std::fs::read(source.join("src/main.rs"))?, unformatted); + let (code, out) = cargo(&runtime, &["fmt"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(std::fs::read_to_string(source.join("src/main.rs"))?.contains("fn main() {")); + let (code, out) = cargo(&runtime, &["remove", "serde"]).await?; + assert_eq!(code, 0, "{out}"); + let (code, out) = cargo(&runtime, &["add", "serde"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(std::fs::read_to_string(source.join("Cargo.toml"))?.contains("serde")); + std::fs::write( + source.join("src/main.rs"), + "fn main() { undefined_function(); }", + )?; + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_ne!(code, 0, "{out}"); + assert!(out.contains("undefined_function"), "{out}"); + Ok(()) +} +#[tokio::test] +async fn timeout_and_cancellation() -> anyhow::Result<()> { + use sausage::builder::{Builder, Event, Request}; + let temp = tempfile::tempdir()?; + let world = World::open(temp.path())?; + let source = temp.path().join("world/project"); + std::fs::create_dir_all(source.join("src"))?; + std::fs::write( + source.join("Cargo.toml"), + "[package]\nname='hang'\nversion='0.1.0'\n[workspace]\n", + )?; + std::fs::write(source.join("src/main.rs"), "fn main() {}")?; + std::fs::write( + 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 (tx, mut rx) = tokio::sync::mpsc::channel(16); + let task = tokio::spawn(async move { + builder + .execute( + Request { + argv: vec!["build".into()], + cwd: "/project".into(), + timeout_secs: Some(3), + }, + &tx, + ) + .await + }); + let mut timed_out = false; + while let Some(event) = rx.recv().await { + if let Event::Exit { code, .. } = event { + timed_out = code == 124; + } + } + task.await??; + assert!(timed_out); + let binaries = programs(); + 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 error = runtime + .exec( + Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]), + Options { + cwd: "/project".into(), + timeout: Some(Duration::from_secs(1)), + ..Default::default() + }, + &mut |_, _| {}, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out"), "{error}"); + let (code, out) = + tokio::time::timeout(Duration::from_secs(20), cargo(&runtime, &["metadata"])).await??; + assert_eq!( + code, 0, + "the canceled build still held the builder lock: {out}" + ); + Ok(()) +} diff --git a/host/tests/hook_git.rs b/host/tests/hook_git.rs index f987076..99ba3e7 100644 --- a/host/tests/hook_git.rs +++ b/host/tests/hook_git.rs @@ -24,7 +24,14 @@ fn git(cwd: &Path, args: &[&str]) { async fn several_mb_git_push() { static BUILT: OnceLock = OnceLock::new(); let component = BUILT.get_or_init(|| { - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_TARGET_TMPDIR")) + .parent() + .unwrap() + .to_owned() + }); let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); if !userland.join("cwd-init/init.o").is_file() { assert!( @@ -71,6 +78,7 @@ async fn several_mb_git_push() { "-c", "user.email=test@example.com", "commit", + "--no-gpg-sign", "-m", "large commit", ], diff --git a/host/tests/hook_guest.rs b/host/tests/hook_guest.rs index 3644367..ab8b22b 100644 --- a/host/tests/hook_guest.rs +++ b/host/tests/hook_guest.rs @@ -12,7 +12,7 @@ fn write(path: PathBuf, text: &str) -> std::io::Result<()> { pub fn component() -> &'static Path { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); + let target = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap().to_owned()); let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest"); std::fs::create_dir_all(directory.join("src")).unwrap(); std::fs::create_dir_all(directory.join("wit/deps")).unwrap(); @@ -68,7 +68,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } "#).unwrap(); - assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", target).status().unwrap().success(), "test guest build failed"); + assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", &target).status().unwrap().success(), "test guest build failed"); target.join("wasm32-wasip2/release/hook-guest.wasm") }) } diff --git a/host/tests/process.rs b/host/tests/process.rs index e659392..27fc711 100644 --- a/host/tests/process.rs +++ b/host/tests/process.rs @@ -350,6 +350,16 @@ fn repo() -> PathBuf { .unwrap() .to_owned() } +fn target() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_TARGET_TMPDIR")) + .parent() + .unwrap() + .to_owned() + }) +} fn build() { BUILD.call_once(|| { let root = repo(); @@ -357,6 +367,7 @@ fn build() { assert!( std::process::Command::new(args[0]) .args(&args[1..]) + .env("CARGO_TARGET_DIR", target()) .current_dir(cwd) .status() .unwrap() @@ -431,8 +442,7 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { let processes = world.processes.clone(); fixture(&temp.path().join("world")); - let artifacts = - PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()).join("wasm32-wasip2/release"); + let artifacts = target().join("wasm32-wasip2/release"); let bin = temp.path().join("world/bin"); for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] { std::fs::copy( diff --git a/userland/Cargo.lock b/userland/Cargo.lock index ce55f3d..c3d1c0c 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -6791,6 +6791,16 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "world-cargo" +version = "0.1.0" +dependencies = [ + "sausage-spawn", + "serde", + "serde_json", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index 01b79be..7f9c962 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/cargo/Cargo.toml b/userland/cargo/Cargo.toml new file mode 100644 index 0000000..4bc28dd --- /dev/null +++ b/userland/cargo/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "world-cargo" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "cargo" +path = "src/main.rs" + +[dependencies] +wasi = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +spawn = { package = "sausage-spawn", path = "../spawn" } diff --git a/userland/cargo/src/lib.rs b/userland/cargo/src/lib.rs new file mode 100644 index 0000000..7eaa064 --- /dev/null +++ b/userland/cargo/src/lib.rs @@ -0,0 +1,146 @@ +//! Stream native compiler events and run the resulting components through the process interface. +use std::io::{self, Write}; +use wasi::http::{outgoing_handler, types::*}; +#[derive(serde::Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +enum Event { + Stdout { + bytes: Vec, + }, + Stderr { + bytes: Vec, + }, + Exit { + code: i32, + executables: Vec, + }, +} +#[derive(serde::Deserialize)] +struct Executable { + path: String, + cwd: String, +} +fn error(value: impl std::fmt::Debug) -> io::Error { + io::Error::other(format!("{value:?}")) +} +pub fn run() -> io::Result { + let argv: Vec = std::env::args().skip(1).collect(); + let data = serde_json::to_vec( + &serde_json::json!({"argv": argv, "cwd": std::env::current_dir()?.to_string_lossy()}), + )?; + let fields = Fields::new(); + fields + .set("content-length", &[data.len().to_string().into_bytes()]) + .map_err(error)?; + fields + .set("content-type", &[b"application/json".to_vec()]) + .map_err(error)?; + let request = OutgoingRequest::new(fields); + request.set_method(&Method::Post).map_err(error)?; + request.set_scheme(Some(&Scheme::Http)).map_err(error)?; + request.set_authority(Some("host.sausage")).map_err(error)?; + request.set_path_with_query(Some("/cargo")).map_err(error)?; + let outgoing = request.body().map_err(error)?; + let options = RequestOptions::new(); + options + .set_between_bytes_timeout(Some(1860 * 1_000_000_000)) + .map_err(error)?; + let future = outgoing_handler::handle(request, Some(options)).map_err(error)?; + let stream = outgoing.write().map_err(error)?; + let mut remaining = data.as_slice(); + while !remaining.is_empty() { + stream.subscribe().block(); + let n = (stream.check_write().map_err(error)? as usize).min(remaining.len()); + stream.write(&remaining[..n]).map_err(error)?; + remaining = &remaining[n..]; + } + drop(stream); + OutgoingBody::finish(outgoing, None).map_err(error)?; + future.subscribe().block(); + let response = future + .get() + .ok_or_else(|| error("missing response"))? + .map_err(error)? + .map_err(error)?; + let incoming = response.consume().map_err(error)?; + let stream = incoming.stream().map_err(error)?; + let mut pending = Vec::new(); + let mut result = None; + loop { + stream.subscribe().block(); + match stream.read(65536) { + Ok(bytes) => pending.extend(bytes), + Err(wasi::io::streams::StreamError::Closed) => break, + Err(e) => return Err(error(e)), + } + while let Some(end) = pending.iter().position(|b| *b == b'\n') { + let line: Vec<_> = pending.drain(..=end).collect(); + match serde_json::from_slice::(&line)? { + Event::Stdout { bytes } => { + io::stdout().write_all(&bytes)?; + io::stdout().flush()?; + } + Event::Stderr { bytes } => { + io::stderr().write_all(&bytes)?; + io::stderr().flush()?; + } + Event::Exit { code, executables } => result = Some((code, executables)), + } + } + } + drop(stream); + drop(IncomingBody::finish(incoming)); + drop(response); + let (code, executables) = result.ok_or_else(|| error("missing Cargo exit event"))?; + if code != 0 { + return Ok(code); + } + let action = argv.first().map(String::as_str).unwrap_or(""); + let args = execution_args(&argv); + for executable in executables { + let code = spawn::Command::new(executable.path) + .current_dir(executable.cwd) + .args(&args) + .status()?; + if code != 0 { + return Ok(if action == "test" { 101 } else { code }); + } + } + Ok(0) +} +fn execution_args(argv: &[String]) -> Vec { + let split = argv.iter().position(|a| a == "--").unwrap_or(argv.len()); + let mut args = argv.get(split + 1..).unwrap_or_default().to_vec(); + if argv.first().is_some_and(|a| a == "test") { + let mut value = false; + for arg in &argv[1..split] { + if !value && !arg.starts_with('-') { + args.insert(0, arg.clone()); + break; + } + value = [ + "-p", + "--package", + "--test", + "--bin", + "--example", + "--features", + "--profile", + "--manifest-path", + "--exclude", + "-j", + "--jobs", + "-F", + "--config", + "--target", + "--target-dir", + "--message-format", + "--color", + "--lockfile-path", + "-Z", + ] + .contains(&arg.as_str()); + } + } + args +} diff --git a/userland/cargo/src/main.rs b/userland/cargo/src/main.rs new file mode 100644 index 0000000..b5f26d7 --- /dev/null +++ b/userland/cargo/src/main.rs @@ -0,0 +1,10 @@ +//! Cargo's world entry point. Compile through HTTP, then execute tests and programs in the world. +fn main() { + match world_cargo::run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("cargo: {error}"); + std::process::exit(1); + } + } +}