diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 70d4431..fb2af4f 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -304,6 +304,18 @@ dependencies = [ "objc2", ] +[[package]] +name = "box-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tar", + "url", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "bstr" version = "1.13.1" @@ -4883,6 +4895,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index cca634f..9a802a7 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"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/box/Cargo.toml b/userland/box/Cargo.toml new file mode 100644 index 0000000..7085b30 --- /dev/null +++ b/userland/box/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "box-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "box" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +wasi = "0.14" +url = "2" +tar = { version = "0.4", default-features = false } diff --git a/userland/box/README.md b/userland/box/README.md new file mode 100644 index 0000000..b139104 --- /dev/null +++ b/userland/box/README.md @@ -0,0 +1,118 @@ +# box + +`box` runs builds and tests in disposable containers through the containers service at `citadel.vpn.surma.technology:8093`. +All network operations use standard `wasi:http`. The component has no Sausage host imports. + +## Commands + +```sh +box create [--flake reference] [--ttl seconds] +box exec ID [--timeout seconds] [--inline-bytes bytes] -- COMMAND ARGUMENTS... +box put ID WORLD_PATH BOX_PATH +box get ID BOX_PATH WORLD_PATH +box logs ID +box ls +box rm ID +``` + +Every command accepts `--host hostname[:port]`. The default port is 8093. +Create remembers the host in `/var/lib/box/hosts/`. Separate files prevent unrelated creates from replacing each other’s mappings. +Other commands use the remembered host unless `--host` overrides it. Unknown IDs use the default service. +Host mappings remain after deletion for diagnostics. They contain no credentials. + +Create prints the ID and waits up to 240 seconds for readiness. +Without a flake, it uses v2’s Node/NixOS configuration with Node, npm, Git, Chromium, fonts, and tar. +A flake selects a service-side configuration, for example `github:OWNER/REPO#container`. +TTL is positive seconds from creation. The service defaults to 3600 seconds. +A failed or timed-out create reports its accepted ID. Do not repeat create blindly. Inspect logs and remove that ID. + +Exec runs as root. It passes an argv array without shell interpolation. +For shell syntax, use `-- sh -c 'COMMAND'`. The timeout defaults to 60 seconds and accepts 1 through 1800 seconds. +Exec prints stdout and stderr separately and returns the command’s exit code. +It does not consume local stdin. Use put for inputs. + +Put detects a local directory. Get detects a remote directory. +File transfers support binary bytes and a 64 MiB limit. Downloads replace a destination file only after complete validation. +Tree transfers use an uncompressed tar with a 64 MiB limit. Put merges into a box directory. +Get requires a new world directory and publishes it only after complete extraction. +The port preserves v2’s portable file modes and hardlink materialization. +Get rejects symlinks because the world VFS cannot create them. Export a dereferenced tree when necessary. +Special files and paths outside the destination fail explicitly. +Temporary tar files disappear after a transfer, including failures where cleanup remains possible. + +## Build a pnpm project in a box + +```sh +id=$(box create --ttl 1800) +box put "$id" /repos/project /work +box exec "$id" --timeout 600 -- sh -c 'cd /work && npm install --global pnpm && pnpm install --frozen-lockfile && pnpm test && pnpm build' +box get "$id" /work/dist /repos/project-dist +box logs "$id" +box rm "$id" +``` + +Use the same ID to inspect failed builds. Always remove disposable boxes when the work ends. +Boxes have open network access. The world’s host credential boundary remains unchanged. + +## Output retention + +The CLI preserves v2’s full output archives independently of presentation. +It downloads both raw streams for new-service executions, including small and binary output. +Files live under `/logs/box/YYYY-MM-DD/-.stdout` and `.stderr`. +Collision snapshots receive unique suffixes. Existing archives never change. +Result JSON retains original service excerpts before the CLI bounds presentation. +The CLI prints archive paths and availability notices on stderr. + +Each displayed stream defaults to a 16 KiB UTF-8 head and tail budget. +The omission marker is outside that budget. `--inline-bytes 0` prints only the marker for nonempty output. +A larger budget reads archives, not an already-truncated service excerpt. UTF-8 replacement remains bounded. +Full archives have no retention cap. Transfer limits do not cap execution archives. + +Downloads request 1 MiB ranges and validate every Content-Range page and exact byte total. +They also accept a complete 200 response from a server that ignores Range. +A shared deadline allows 60 seconds plus one second per 256 KiB of combined output, capped at 30 minutes. +Unknown totals receive five minutes. Only immutable archive GETs retry. Exec POSTs never retry. +Incomplete archives retain `.partial` names. Only complete, flushed downloads receive published paths. + +Build logs try `/log/full`. Only 404 or 405 triggers `/log` fallback. +The CLI retains all available raw build bytes and displays at most their last 16 KiB. +A fallback warning states that earlier bytes may be unavailable. +Failed creates archive build diagnostics. Explicit removal attempts a build snapshot but proceeds if that snapshot fails. + +Old services expose only JSON stream text. The CLI saves all available text before presentation limits. +It warns when upstream truncation discarded bytes. Those missing bytes cannot be restored. +Archive failures return the completed command and an availability notice. Never replay a command to repair its archive. + +## Build and validation + +Build the shared startup object from the repository root: + +```sh +nix develop -c nu --no-config-file userland/cwd-init/build.nu +``` + +Run the following commands from `userland/` so Cargo loads the shared startup and numeric exit support: + +```sh +df -h / +export CARGO_TARGET_DIR=/home/surma/src/gitea.surma.technology/surma/sausage/target/p1-tools +nix develop -c cargo build -p box-cli --target wasm32-wasip2 --release --locked +nix develop -c kache stats +nix develop -c nu box/test.nu +``` + +The existing `userland/bundle.nu` discovers the workspace member and copies its `box` binary into `/bin`. +It applies the same cwd initializer as other bundled programs. + +Keep the same target directory for tests. Do not override `CARGO_HOME` or `RUSTC_WRAPPER`. +Stop before a build if disk use exceeds 85%. +Run pure unit tests with the native target from `rustc -vV`, for example: + +```sh +nix develop -c cargo test -p box-cli --target aarch64-unknown-linux-gnu --locked +``` + +The real-service script tests every required command and removes every created box, including after an assertion failure. +It round-trips files and nested trees, runs a tiny Node project through npm test, and retrieves its result. +It also verifies large binary archive retention and WASI-only component imports. +The script preserves its temporary world and archives as evidence. diff --git a/userland/box/src/archive.rs b/userland/box/src/archive.rs new file mode 100644 index 0000000..6e560b6 --- /dev/null +++ b/userland/box/src/archive.rs @@ -0,0 +1,237 @@ +//! Raw archive downloads. Range pages are validated before advancing; no byte caps. +use crate::{Result, error, http, retention}; +use std::io::{Read, Seek, SeekFrom, Write}; +use wasi::http::types::Method; + +pub struct Download { + pub path: String, + pub bytes: u64, +} + +// Only immutable archive GETs are retried, never the command POST. Divide the +// remaining total budget so a stalled first attempt cannot consume every retry. +// Failed partial files remain explicitly partial; only verified downloads publish. +pub fn exec_download( + h: &str, + url: &str, + name: &str, + extension: &str, + deadline: u64, + expected: Option, +) -> Result { + let mut last = error("exec archive deadline exceeded"); + for remaining in (1..=3).rev() { + let now = http::now(); + if now >= deadline { + break; + } + let attempt_deadline = now.saturating_add((deadline - now) / remaining); + match download_verified(h, url, name, extension, attempt_deadline, false, expected) { + Ok(Some(saved)) => return Ok(saved), + Ok(None) => return Err(error("missing exec archive")), + Err(e) => { + // Validation/local storage errors are not transient network failures. + let retry = [ + "HTTP failed:", + "HTTP start:", + "archive download failed:", + "deadline exceeded", + "incomplete archive page", + "HTTP 500", + "HTTP 502", + "HTTP 503", + "HTTP 504", + ] + .iter() + .any(|message| e.message.contains(message)); + if !retry { + return Err(e); + } + eprintln!( + "box archive GET attempt {} failed: {}", + 4 - remaining, + e.message + ); + last = e; + } + } + } + Err(last) +} + +pub fn download( + h: &str, + url: &str, + name: &str, + extension: &str, + deadline: u64, + missing: bool, +) -> Result> { + download_verified(h, url, name, extension, deadline, missing, None) +} + +pub fn download_verified( + h: &str, + url: &str, + name: &str, + extension: &str, + deadline: u64, + missing: bool, + expected: Option, +) -> Result> { + let mut offset = 0u64; + let mut total = None; + let mut output = None; + let result = (|| { + loop { + let range = format!("bytes={offset}-{}", offset.saturating_add(1024 * 1024 - 1)); + let mut response = http::request_options( + h, + Method::Get, + url, + None, + 30, + Some(deadline), + Some(&range), + missing && offset == 0, + )?; + if matches!(response.status, 404 | 405) { + return Ok(None); + } + let page = if response.status == 206 { + let (start, end, size) = + parse_range(response.content_range.as_deref().unwrap_or(""))?; + if start != offset || total.is_some_and(|t| t != size) { + return Err(error("inconsistent archive range")); + } + total = Some(size); + Some(end - start + 1) + } else if response.status == 200 && offset == 0 { + None + } else { + return Err(error("unexpected archive response status")); + }; + if output.is_none() { + output = Some(retention::create(name, &format!("{extension}.partial"))?); + } + let (path, file) = output + .as_mut() + .ok_or_else(|| error("archive file unavailable"))?; + let count = std::io::copy(&mut response, file).map_err(|e| { + error(format!( + "archive download failed: {e}; partial file: {path}" + )) + })?; + if page.is_some_and(|n| n != count) { + return Err(error(format!( + "incomplete archive page; partial file: {path}" + ))); + } + offset = offset + .checked_add(count) + .ok_or_else(|| error("archive size overflow"))?; + if page.is_none() || total == Some(offset) { + if expected.is_some_and(|n| n != offset) { + return Err(error("archive byte total mismatch")); + } + file.flush()?; + // Reserve a collision-safe final name; replace only our own empty reservation. + let (complete, reservation) = retention::create(name, extension)?; + drop(reservation); + if let Err(e) = std::fs::rename(&*path, &complete) { + let _ = std::fs::remove_file(&complete); + return Err(e.into()); + } + return Ok(Some(Download { + path: complete, + bytes: offset, + })); + } + } + })(); + result.map_err(|mut e: crate::Error| { + if let Some((path, _)) = output { + e.message.push_str(&format!("; incomplete archive: {path}")); + } + e + }) +} +fn parse_range(value: &str) -> Result<(u64, u64, u64)> { + let parsed = (|| { + let (span, size) = value.strip_prefix("bytes ")?.split_once('/')?; + let (start, end) = span.split_once('-')?; + Some(( + start.parse::().ok()?, + end.parse::().ok()?, + size.parse::().ok()?, + )) + })(); + match parsed { + Some((s, e, n)) if s <= e && e < n => Ok((s, e, n)), + _ => Err(error("invalid archive Content-Range")), + } +} + +// Presentation limit only: the complete raw response is always retained on disk. +pub const BUILD_EXCERPT_BYTES: u64 = 16 * 1024; +fn build_result(full: Download, service_truncated: bool) -> Result { + let mut file = std::fs::File::open(&full.path)?; + let start = full.bytes.saturating_sub(BUILD_EXCERPT_BYTES); + file.seek(SeekFrom::Start(start))?; + let mut bytes = Vec::new(); + file.take(BUILD_EXCERPT_BYTES).read_to_end(&mut bytes)?; + let mut notices = Vec::new(); + if service_truncated { + notices.push("Full build-log endpoint unavailable (unsupported service or missing archive); legacy endpoint may return only the last 64 KiB, so earlier bytes may be unavailable.".to_owned()); + } + if start > 0 { + notices.push(format!("Inline tail shows raw byte range {start}..{} (end exclusive); full {} bytes retained at {}.", full.bytes, full.bytes, full.path)); + } + Ok(crate::Log { + text: String::from_utf8_lossy(&bytes).into_owned(), + path: full.path, + bytes: full.bytes, + service_truncated, + notice: if notices.is_empty() { + None + } else { + Some(notices.join(" ")) + }, + }) +} + +pub fn build(h: &str, id: &str, deadline: u64) -> Result { + let base = crate::base(id)?; + let name = format!("{id}-build"); + if let Some(full) = download(h, &format!("{base}/log/full"), &name, "log", deadline, true)? { + return build_result(full, false); + } + let legacy = download(h, &format!("{base}/log"), &name, "log", deadline, false)? + .ok_or_else(|| error("missing legacy build log"))?; + build_result(legacy, true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn range_pages_require_valid_totals() { + assert_eq!( + parse_range("bytes 0-1048575/1200003").unwrap(), + (0, 1048575, 1200003) + ); + assert_eq!( + parse_range("bytes 1048576-1200002/1200003").unwrap(), + (1048576, 1200002, 1200003) + ); + for invalid in [ + "", + "bytes 1-0/2", + "bytes 0-2/2", + "bytes 0-0/0", + "bytes 0-1/*", + ] { + assert!(parse_range(invalid).is_err()); + } + } +} diff --git a/userland/box/src/cli.rs b/userland/box/src/cli.rs new file mode 100644 index 0000000..f10685d --- /dev/null +++ b/userland/box/src/cli.rs @@ -0,0 +1,264 @@ +//! Command arguments and presentation. Raw execution archives never depend on the inline budget. +use crate::*; +use clap::{Parser, Subcommand}; +#[derive(Parser, Debug)] +#[command(name = "box", about = "Disposable build and test boxes")] +pub struct Args { + #[arg(long, global = true, help = "Containers service hostname[:port]")] + pub host: Option, + #[command(subcommand)] + pub command: Operation, +} +#[derive(Subcommand, Debug)] +pub enum Operation { + Create { + #[arg(long)] + flake: Option, + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + ttl: Option, + }, + Exec { + id: String, + #[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u32).range(1..=1800))] + timeout: u32, + #[arg(long, default_value_t = excerpt::DEFAULT_BYTES)] + inline_bytes: u32, + #[arg(last = true, required = true)] + cmd: Vec, + }, + Put { + id: String, + world_path: String, + box_path: String, + }, + Get { + id: String, + box_path: String, + world_path: String, + }, + Logs { + id: String, + }, + Ls, + Rm { + id: String, + }, +} +fn notices(result: &Executed) { + for (name, path) in [ + ("stdout", &result.stdout_path), + ("stderr", &result.stderr_path), + ("result", &result.output_log), + ] { + if let Some(path) = path { + eprintln!("box {name} archive: {path}"); + } + } + if let Some(notice) = &result.output_notice { + eprintln!("box: {notice}"); + } + if result.timed_out { + eprintln!("box: command timed out"); + } +} +pub fn run(args: Args) -> Result { + let h = args.host; + let service = BoxPlugin; + match args.command { + Operation::Create { flake, ttl } => { + let config = Config { + nixos: flake.is_none().then(|| Nixos::Text(NODE_MODULE.into())), + flake, + system: None, + }; + let c = service.create(Create { + config, + ttl_secs: ttl, + owner: Some("box CLI".into()), + host: h, + })?; + println!("{}", c.id); + if c.state != "running" { + if let Some(tail) = c.build_log_tail { + eprintln!("{tail}"); + } + if let Some(path) = c.build_log_path { + eprintln!("box build archive: {path}"); + } + return Err(error(format!( + "box {} failed: {}; remove with box rm", + c.id, + c.error.unwrap_or_default() + ))); + } + } + Operation::Exec { + id, + timeout, + inline_bytes, + cmd, + } => { + let result = service.exec(Exec { + id, + cmd: Command::Argv(cmd), + stdin: None, + timeout_secs: Some(timeout), + inline_bytes: Some(inline_bytes), + host: h, + })?; + std::io::stdout().write_all(result.stdout.as_bytes())?; + std::io::stderr().write_all(result.stderr.as_bytes())?; + notices(&result); + return Ok(result.exit); + } + Operation::Put { + id, + world_path, + box_path, + } => { + if Path::new(&world_path).is_dir() { + service.put_tree(PutTree { + id, + from_vfs_dir: world_path, + to_dir: box_path, + host: h, + })?; + } else { + service.put_file(PutFile { + id, + path: box_path, + text: None, + from_vfs: Some(world_path), + host: h, + })?; + } + } + Operation::Get { + id, + box_path, + world_path, + } => { + let host = resolve(&id, h.as_deref())?; + let kind = checked( + &host, + &id, + json!([ + "sh", + "-c", + "if [ -d \"$1\" ]; then printf directory; elif [ -f \"$1\" ]; then printf file; else exit 1; fi", + "sh", + box_path + ]), + )?; + if kind == "directory" { + service.get_tree(GetTree { + id, + from_dir: box_path, + to_vfs_dir: world_path, + host: h, + })?; + } else { + service.get_file(GetFile { + id, + path: box_path, + to_vfs: Some(world_path), + host: h, + })?; + } + } + Operation::Logs { id } => { + let log = service.log(Id { id, host: h })?; + print!("{}", log.text); + eprintln!("box build archive: {} ({} bytes)", log.path, log.bytes); + if let Some(notice) = log.notice { + eprintln!("box: {notice}"); + } + } + Operation::Ls => { + let list = service.list(List { host: h })?; + for c in list.containers { + println!("{}\t{}\t{}", c.id, c.state, c.expires_at); + } + } + Operation::Rm { id } => { + service.delete(Id { id, host: h })?; + } + } + Ok(0) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn argument_forms_and_validation() { + for args in [ + vec!["box", "create"], + vec![ + "box", + "create", + "--flake", + "github:example/project#container", + "--ttl", + "600", + ], + vec![ + "box", + "exec", + "tc-abc123", + "--timeout", + "5", + "--", + "sh", + "-c", + "exit 7", + ], + vec!["box", "put", "tc-abc123", "dir", "/work"], + vec!["box", "get", "tc-abc123", "/work", "out"], + vec!["box", "logs", "tc-abc123"], + vec!["box", "ls"], + vec!["box", "rm", "tc-abc123"], + vec!["box", "--host", "localhost:8093", "ls"], + ] { + assert!(Args::try_parse_from(args).is_ok()); + } + for args in [ + vec!["box", "create", "--ttl", "0"], + vec!["box", "exec", "tc-abc123", "--"], + vec![ + "box", + "exec", + "tc-abc123", + "--timeout", + "1801", + "--", + "true", + ], + vec!["box", "exec", "tc-abc123", "true"], + vec!["box", "put", "tc-abc123", "a"], + vec!["box", "unknown"], + ] { + assert!(Args::try_parse_from(args).is_err()); + } + let args = Args::try_parse_from([ + "box", + "exec", + "tc-abc123", + "--inline-bytes", + "0", + "--", + "echo", + "--flag", + "space here", + ]) + .unwrap(); + match args.command { + Operation::Exec { + cmd, inline_bytes, .. + } => { + assert_eq!(cmd, ["echo", "--flag", "space here"]); + assert_eq!(inline_bytes, 0); + } + _ => panic!("wrong operation"), + } + } +} diff --git a/userland/box/src/excerpt.rs b/userland/box/src/excerpt.rs new file mode 100644 index 0000000..66fbf1b --- /dev/null +++ b/userland/box/src/excerpt.rs @@ -0,0 +1,108 @@ +//! Presentation only. Never rewrite archives or change internal command output. +use crate::{Executed, Result}; +use std::io::{Read, Seek, SeekFrom}; + +pub const DEFAULT_BYTES: u32 = 16 * 1024; +const MARKER: &str = + "\n... [inline output omitted; see stream archive path for full available output] ...\n"; + +fn head(text: &str, bytes: usize) -> &str { + let mut end = bytes.min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} +fn tail(text: &str, bytes: usize) -> &str { + let mut start = text.len().saturating_sub(bytes); + while !text.is_char_boundary(start) { + start += 1; + } + &text[start..] +} +fn bounded(text: &str, budget: usize) -> (String, bool) { + if text.len() <= budget { + return (text.to_owned(), false); + } + let first = budget.div_ceil(2); + ( + format!( + "{}{MARKER}{}", + head(text, first), + tail(text, budget - first) + ), + true, + ) +} +fn from_file(path: &str, budget: usize) -> Result<(String, bool)> { + let mut file = std::fs::File::open(path)?; + let size = file.metadata()?.len(); + if size <= budget as u64 { + let mut bytes = Vec::new(); + file.take(budget as u64).read_to_end(&mut bytes)?; + return Ok(bounded(&String::from_utf8_lossy(&bytes), budget)); + } + let first = budget.div_ceil(2); + let last = budget - first; + let mut begin = Vec::new(); + (&mut file).take(first as u64).read_to_end(&mut begin)?; + file.seek(SeekFrom::Start(size - last as u64))?; + let mut end = Vec::new(); + file.take(last as u64).read_to_end(&mut end)?; + // UTF-8 replacement can expand binary input; bound displayed UTF-8 too. + Ok(( + format!( + "{}{MARKER}{}", + head(&String::from_utf8_lossy(&begin), first), + tail(&String::from_utf8_lossy(&end), last) + ), + true, + )) +} +pub fn apply(result: &mut Executed, budget: u32) { + let service_cut = result.truncated; + result.truncated = result.service_truncated; + for (stream, text, path) in [ + ("stdout", &mut result.stdout, &result.stdout_path), + ("stderr", &mut result.stderr, &result.stderr_path), + ] { + let excerpt = path.as_deref().map(|p| from_file(p, budget as usize)); + let (display, cut) = match excerpt { + Some(Ok(value)) => value, + Some(Err(e)) => { + result + .output_notice + .get_or_insert_with(String::new) + .push_str(&format!( + "{stream} excerpt could not read archive: {}; showing service excerpt. ", + e.message + )); + let (display, cut) = bounded(text, budget as usize); + (display, cut || service_cut) + } + None => { + let (display, cut) = bounded(text, budget as usize); + (display, cut || service_cut) + } + }; + *text = display; + result.truncated |= cut; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn utf8_and_tiny_budgets_are_bounded() { + for budget in 0..40 { + let text = "🦀é".repeat(20); + let (value, cut) = bounded(&text, budget); + assert!(cut); + assert!(value.len() <= budget + MARKER.len()); + assert!(value.contains(MARKER)); + } + assert_eq!(bounded("", 0), (String::new(), false)); + assert_eq!(bounded("small", 5), ("small".into(), false)); + } +} diff --git a/userland/box/src/http.rs b/userland/box/src/http.rs new file mode 100644 index 0000000..d383c1c --- /dev/null +++ b/userland/box/src/http.rs @@ -0,0 +1,265 @@ +//! Small synchronous wasi:http adapter, following git's stream lifecycle. +use crate::{Result, error}; +use std::io::{self, Read}; +use wasi::http::{outgoing_handler, types::*}; + +#[cfg(test)] +thread_local! { pub static SERVER: std::cell::RefCell>> = Default::default(); } + +// A call shares one monotonic deadline across response headers/body. +pub fn now() -> u64 { + wasi::clocks::monotonic_clock::now() +} +fn wait(ready: &wasi::io::poll::Pollable, deadline: Option) -> io::Result<()> { + if let Some(deadline) = deadline { + if now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "box HTTP deadline exceeded", + )); + } + let timer = wasi::clocks::monotonic_clock::subscribe_instant(deadline); + wasi::io::poll::poll(&[ready, &timer]); + if now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "box HTTP deadline exceeded", + )); + } + } else { + ready.block(); + } + Ok(()) +} +pub struct Incoming { + pub status: u16, + pub content_range: Option, + stream: Option, + body: Option, + deadline: Option, +} +impl Read for Incoming { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + let Some(stream) = self.stream.as_ref() else { + return Ok(0); + }; + loop { + wait(&stream.subscribe(), self.deadline)?; + match stream.read(buf.len().min(65536) as u64) { + Ok(b) if b.is_empty() => continue, + Ok(b) => { + buf[..b.len()].copy_from_slice(&b); + return Ok(b.len()); + } + Err(wasi::io::streams::StreamError::Closed) => { + self.stream.take(); + if let Some(body) = self.body.take() { + let trailers = IncomingBody::finish(body); + wait(&trailers.subscribe(), self.deadline)?; + if !matches!(trailers.get(), Some(Ok(Ok(_)))) { + return Err(io::Error::other("HTTP response body failed")); + } + } + return Ok(0); + } + Err(_) => return Err(io::Error::other("HTTP read failed or timed out")), + } + } + } +} +impl Drop for Incoming { + fn drop(&mut self) { + self.stream.take(); + self.body.take(); + } +} + +pub fn request( + host: &str, + method: Method, + path: &str, + input: Option<(&mut dyn Read, u64)>, + secs: u32, +) -> Result { + request_until(host, method, path, input, secs, None) +} +pub fn request_until( + host: &str, + method: Method, + path: &str, + input: Option<(&mut dyn Read, u64)>, + secs: u32, + deadline: Option, +) -> Result { + request_options(host, method, path, input, secs, deadline, None, false) +} +// Keep the wire options explicit, as in the v2 adapter. +#[allow(clippy::too_many_arguments)] +pub fn request_options( + host: &str, + method: Method, + path: &str, + mut input: Option<(&mut dyn Read, u64)>, + secs: u32, + deadline: Option, + range: Option<&str>, + allow_missing: bool, +) -> Result { + let fields = Fields::new(); + if let Some(range) = range { + fields + .append("range", range.as_bytes()) + .map_err(|_| error("invalid range"))?; + } + fields + .append( + "content-length", + &input + .as_ref() + .map_or(0, |(_, len)| *len) + .to_string() + .into_bytes(), + ) + .map_err(|_| error("invalid length"))?; + if input.is_some() { + fields + .append("content-type", b"application/json") + .map_err(|_| error("invalid content type"))?; + } + let req = OutgoingRequest::new(fields); + req.set_method(&method) + .map_err(|_| error("invalid method"))?; + req.set_scheme(Some(&Scheme::Http)) + .map_err(|_| error("invalid scheme"))?; + req.set_authority(Some(host)) + .map_err(|_| error("invalid host"))?; + req.set_path_with_query(Some(path)) + .map_err(|_| error("invalid path"))?; + let body = req + .body() + .map_err(|_| error("HTTP request body unavailable"))?; + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(5_000_000_000)) + .map_err(|_| error("connect timeout unsupported"))?; + options + .set_first_byte_timeout(Some((secs as u64 + 30) * 1_000_000_000)) + .map_err(|_| error("timeout unsupported"))?; + options + .set_between_bytes_timeout(Some(30_000_000_000)) + .map_err(|_| error("timeout unsupported"))?; + let future = outgoing_handler::handle(req, Some(options)) + .map_err(|e| error(format!("HTTP start: {e:?}")))?; + if let Some((reader, _)) = input.as_mut() { + let stream = body.write().map_err(|_| error("HTTP body not writable"))?; + let mut buf = [0; 4096]; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + { + let mut offset = 0; + while offset < n { + wait(&stream.subscribe(), deadline)?; + let available = stream + .check_write() + .map_err(|_| error("HTTP write failed"))? + as usize; + let count = available.min(n - offset); + if count > 0 { + stream + .write(&buf[offset..offset + count]) + .map_err(|_| error("HTTP write failed"))?; + offset += count; + } + } + } + } + drop(stream); + } + OutgoingBody::finish(body, None).map_err(|_| error("HTTP finish failed"))?; + #[cfg(test)] + SERVER.with(|s| { + if let Some(f) = s.borrow_mut().as_mut() { + f(); + } + }); + wait(&future.subscribe(), deadline)?; + let response = future + .get() + .ok_or_else(|| error("HTTP not ready"))? + .map_err(|_| error("HTTP already consumed"))? + .map_err(|e| error(format!("HTTP failed: {e:?}")))?; + let status = response.status(); + let content_range = response + .headers() + .get("content-range") + .first() + .map(|v| String::from_utf8_lossy(v).into_owned()); + let body = response + .consume() + .map_err(|_| error("HTTP body unavailable"))?; + let stream = body + .stream() + .map_err(|_| error("HTTP stream unavailable"))?; + let mut incoming = Incoming { + status, + content_range, + stream: Some(stream), + body: Some(body), + deadline, + }; + if !(200..300).contains(&status) && !(allow_missing && matches!(status, 404 | 405)) { + let mut bytes = Vec::new(); + incoming.read_to_end(&mut bytes)?; + return match crate::retention::save("box", "http-error", &bytes) { + Ok(path) => { + let reason = if bytes.len() <= 1024 { + format!("; {}", String::from_utf8_lossy(&bytes)) + } else { + String::new() + }; + Err(error(format!( + "box service {host}: HTTP {status}{reason}; {} bytes; full response: {path}", + bytes.len() + ))) + } + Err(e) => Err(error(format!( + "box service {host}: HTTP {status}; archive failed: {e}; full response: {}", + String::from_utf8_lossy(&bytes) + ))), + }; + } + Ok(incoming) +} + +pub fn json( + host: &str, + method: Method, + path: &str, + value: Option, + secs: u32, +) -> Result { + json_until(host, method, path, value, secs, None) +} +pub fn json_until( + host: &str, + method: Method, + path: &str, + value: Option, + secs: u32, + deadline: Option, +) -> Result { + let data = value.map(|v| serde_json::to_vec(&v)).transpose()?; + let mut cursor = std::io::Cursor::new(data.as_deref().unwrap_or_default()); + let body = data + .as_ref() + .map(|d| (&mut cursor as &mut dyn Read, d.len() as u64)); + Ok(serde_json::from_reader(request_until( + host, method, path, body, secs, deadline, + )?)?) +} diff --git a/userland/box/src/lib.rs b/userland/box/src/lib.rs new file mode 100644 index 0000000..a995a53 --- /dev/null +++ b/userland/box/src/lib.rs @@ -0,0 +1,746 @@ +//! Containers API, raw output archives, and bounded world transfers. +pub mod cli; +#[derive(Debug)] +pub struct Error { + pub message: String, + pub data: Option, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for Error {} +impl From for Error { + fn from(e: std::io::Error) -> Self { + error(e.to_string()) + } +} +impl From for Error { + fn from(e: serde_json::Error) -> Self { + error(e.to_string()) + } +} +pub type Result = std::result::Result; +fn error(s: impl Into) -> Error { + Error { + message: s.into(), + data: None, + } +} +fn pid() -> String { + std::env::var("SAUSAGE_PID").unwrap_or_else(|_| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_string() + }) +} +use serde_json::{Value, json}; +use std::{ + fs::File, + io::{Read, Write}, + path::Path, +}; +use wasi::http::types::Method; +mod archive; +mod excerpt; +mod http; +mod retention; +mod transfer; + +const DEFAULT_HOST: &str = "citadel.vpn.surma.technology:8093"; +const MAX_FILE: u64 = 64 << 20; +pub const NODE_MODULE: &str = r#"{ pkgs, ... }: { + environment.systemPackages = with pkgs; [ nodejs git chromium fontconfig gnutar ]; + fonts.packages = [ pkgs.dejavu_fonts ]; + environment.variables = { + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = "${pkgs.chromium}/bin/chromium"; + FONTCONFIG_FILE = "${pkgs.fontconfig.out}/etc/fonts/fonts.conf"; + FONTCONFIG_PATH = "${pkgs.fontconfig.out}/etc/fonts"; + }; +}"#; + +// The service has two genuine JSON unions. Keep these wire types separate from +// #[derive(serde::Serialize, serde::Deserialize)] structs; the SDK subset cannot express unions. No arbitrary result JSON. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum Command { + Shell(String), + Argv(Vec), +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum Nixos { + Text(String), + Options(std::collections::BTreeMap), +} + +/// Exactly one Nix configuration source. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Config { + /// Flake reference, optionally #configuration (default container). + flake: Option, + /// NixOS module text or JSON option object. + nixos: Option, + /// Already built /nix/store system path on the service host. + system: Option, +} +/// Create a disposable container and wait for readiness. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Create { + /// Exactly one of flake, nixos, system. + config: Config, + /// Lifetime from creation, service default 3600 seconds. + ttl_secs: Option, + /// Informational owner label. + owner: Option, + /// Service hostname or hostname:port, default citadel:8093. + host: Option, +} +/// Select a service for listing boxes. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct List { + /// Hostname or hostname:port; default citadel:8093. + host: Option, +} +/// Select a box; remembered host from create is used by default. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Id { + /// Service-issued tc-xxxxxx ID. + id: String, + /// Override remembered host; default citadel:8093 for unknown IDs. + host: Option, +} +/// Service metadata, with build-log tail attached to failed creates. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Container { + /// Container ID. + id: String, + /// creating, running, updating or failed. + state: String, + /// Private container IP, accessible from service host. + address: String, + /// UTC creation timestamp. + created_at: String, + /// UTC expiry timestamp. + expires_at: String, + /// Informational owner. + owner: String, + /// Configuration used to create this container. + config: Config, + /// Service failure message. + error: Option, + /// Bounded build-log tail when creation fails; full available bytes are at build_log_path. + #[serde(default)] + build_log_tail: Option, + /// Permanent archive of the build diagnostic returned by the service. + #[serde(default)] + build_log_path: Option, + /// Exact archived byte count, not a total for old-service tails. + #[serde(default)] + build_log_bytes: Option, + /// True when the legacy endpoint was used and may have returned only a tail. + #[serde(default)] + build_log_service_truncated: Option, + /// Inline-tail byte range or compatibility warning, if applicable. + #[serde(default)] + build_log_notice: Option, +} +/// Service inventory. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Containers { + /// Containers currently known to this host. + containers: Vec, +} +/// Extend expiry to now plus ttl_secs (not additive). +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Extend { + /// Container ID. + id: String, + /// Positive remaining lifetime. + ttl_secs: u32, + /// Override remembered host. + host: Option, +} +/// Execute as root inside a running box. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Exec { + /// Container ID. + id: String, + /// Shell string (sh -c) or nonempty argv array. + cmd: Command, + /// UTF-8 standard input. + stdin: Option, + /// Default 60, maximum 1800 seconds. + timeout_secs: Option, + /// Inline UTF-8 byte budget per stream (default 16384), excluding the omission marker. Zero omits stream text. Full archives are unaffected. + inline_bytes: Option, + /// Override remembered host. + host: Option, +} +/// Command completion, including timeout/output-limit status. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Executed { + /// Process exit code. + exit: i32, + /// Bounded stdout head and tail; full available bytes are in stdout_path. + stdout: String, + /// Bounded stderr head and tail; full available bytes are in stderr_path. + stderr: String, + /// Service killed the command after its timeout. + timed_out: bool, + /// Inline output was cut by the service or plugin; service_truncated distinguishes upstream loss. + truncated: bool, + /// Permanent archive of service result metadata and inline excerpts. + #[serde(default)] + output_log: Option, + /// Service execution identifier (new services only). + #[serde(default)] + exec_id: Option, + /// Exact raw stdout total on new services; available UTF-8 bytes on old services (which may have lost output). + #[serde(default)] + stdout_bytes: Option, + /// Exact raw stderr total on new services; available UTF-8 bytes on old services (which may have lost output). + #[serde(default)] + stderr_bytes: Option, + /// Service download reference when stdout exceeded the inline limit. + #[serde(default)] + stdout_url: Option, + /// Service download reference when stderr exceeded the inline limit. + #[serde(default)] + stderr_url: Option, + /// Permanent full raw stdout archive (available UTF-8 text on old services). + #[serde(default)] + stdout_path: Option, + /// Permanent full raw stderr archive (available UTF-8 text on old services). + #[serde(default)] + stderr_path: Option, + /// True if the old service discarded output beyond its cap. + #[serde(default)] + service_truncated: bool, + /// Output availability or archival failure; execution already completed, do not replay. + #[serde(default)] + output_notice: Option, +} +/// A successful mutation. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct OkResult { + /// True on success. + ok: bool, +} +/// Build log response. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Log { + /// At most the last 16 KiB of raw build bytes, displayed with UTF-8 replacement; full bytes are at path. + text: String, + /// Permanent world archive path. + path: String, + /// Exact byte count in the archived file. + #[serde(default)] + bytes: u64, + /// Legacy endpoint used; the full build log may be unavailable. + #[serde(default)] + service_truncated: bool, + /// Inline-tail byte range or compatibility warning, if applicable. + #[serde(default)] + notice: Option, +} +/// Upload UTF-8 text or stream a VFS file. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PutFile { + /// Container ID. + id: String, + /// Absolute path inside the box. + path: String, + /// Inline text; exactly one of text and from_vfs is required. + text: Option, + /// World file to stream, including binary data, at most 64 MiB. + from_vfs: Option, + /// Override remembered host. + host: Option, +} +/// Download a file, optionally streaming to the world. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct GetFile { + /// Container ID. + id: String, + /// Absolute file path inside the box (not a directory). + path: String, + /// World destination; atomically replaced only on successful complete download. + to_vfs: Option, + /// Override remembered host. + host: Option, +} +/// File transfer result; payload omitted for VFS transfers. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct FileResult { + /// Bytes transferred. + bytes: u64, + /// UTF-8 contents for inline downloads, capped at 64 KiB. + text: Option, + /// Destination world path, if any. + to_vfs: Option, +} +/// Copy a world directory into the box using an uncompressed tar. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PutTree { + /// Container ID. + id: String, + /// World source directory. + from_vfs_dir: String, + /// Absolute destination inside the box. Existing files may be overwritten. + to_dir: String, + /// Override remembered host. + host: Option, +} +/// Copy a box directory into a new world directory using tar. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct GetTree { + /// Container ID. + id: String, + /// Absolute source directory inside the box. + from_dir: String, + /// New world destination directory; must not already exist. + to_vfs_dir: String, + /// Override remembered host. + host: Option, +} +/// Ready-to-use JavaScript and Chromium container. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct NodeBox { + /// Lifetime in seconds; service default 3600. + ttl_secs: Option, +} +/// Disposable build/test boxes. No background workers or startup side effects. +pub struct BoxPlugin; + +fn host(input: Option<&str>) -> Result { + let h = input.unwrap_or(DEFAULT_HOST); + let h = if h.contains(':') { + h.to_owned() + } else { + format!("{h}:8093") + }; + let u = + url::Url::parse(&format!("http://{h}")).map_err(|e| error(format!("invalid host: {e}")))?; + if u.host_str().is_none() || u.path() != "/" || u.query().is_some() || u.fragment().is_some() { + return Err(error("host must be hostname or hostname:port")); + } + Ok(h) +} +fn base(id: &str) -> Result { + if id.len() != 9 + || !id.starts_with("tc-") + || !id[3..] + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return Err(error("invalid box ID; expected tc-xxxxxx")); + } + Ok(format!("/containers/{id}")) +} +const HOSTS: &str = "/var/lib/box/hosts"; +fn remember(id: &str, h: &str) -> Result<()> { + base(id)?; + std::fs::create_dir_all(HOSTS)?; + let path = Path::new(HOSTS).join(id); + if let Ok(old) = std::fs::read_to_string(&path) { + if old != h { + return Err(error(format!( + "box ID collision for {id}; specify --host explicitly" + ))); + } + return Ok(()); + } + let temp = path.with_extension(pid()); + std::fs::write(&temp, h)?; + std::fs::rename(temp, path)?; + Ok(()) +} +fn resolve(id: &str, h: Option<&str>) -> Result { + base(id)?; + if let Some(h) = h { + return host(Some(h)); + } + match std::fs::read_to_string(Path::new(HOSTS).join(id)) { + Ok(h) => Ok(h), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DEFAULT_HOST.into()), + Err(e) => Err(e.into()), + } +} +fn get(h: &str, id: &str) -> Result { + http::json(h, Method::Get, &base(id)?, None, 30) +} +fn log(h: &str, id: &str) -> Result { + archive::build(h, id, http::now().saturating_add(60_000_000_000)) +} +// Shared stdout/stderr budget: 60 s overhead plus 1 s per 256 KiB, capped at +// 30 min. Unknown totals get 5 min. HTTP idle timeouts still catch stalled peers. +fn archive_budget_ns(stdout: Option, stderr: Option) -> u64 { + let seconds = match (stdout, stderr) { + (Some(out), Some(err)) => { + 60u64.saturating_add(out.saturating_add(err).div_ceil(256 * 1024)) + } + _ => 300, + }; + seconds.min(1800) * 1_000_000_000 +} +fn execute(h: &str, id: &str, cmd: Value, stdin: Option, secs: u32) -> Result { + let mut result: Executed = http::json( + h, + Method::Post, + &format!("{}/exec", base(id)?), + Some( + json!({"cmd":cmd,"stdin":stdin.unwrap_or_default(),"timeout_secs":secs.clamp(1,1800)}), + ), + secs.clamp(1, 1800), + )?; + result.service_truncated = result.truncated && result.exec_id.is_none(); + if result.service_truncated { + result.output_notice = Some("Old service cut output at its stream cap; bytes beyond the inline output are unavailable.".into()); + } + // Fetch raw streams, never concatenate JSON excerpts (which can replace binary/UTF-8 bytes). + if let Some(exec_id) = result.exec_id.as_deref() { + let deadline = + http::now().saturating_add(archive_budget_ns(result.stdout_bytes, result.stderr_bytes)); + for (stream, url, count, path) in [ + ( + "stdout", + &result.stdout_url, + &mut result.stdout_bytes, + &mut result.stdout_path, + ), + ( + "stderr", + &result.stderr_url, + &mut result.stderr_bytes, + &mut result.stderr_path, + ), + ] { + // Known-empty streams need no network roundtrip (even if a URL was supplied). + // Keep the usual on-disk contract for callers reading stdout_path. + let name = format!("{id}-{exec_id}"); + let saved = if *count == Some(0) { + retention::create(&name, stream) + .map(|(path, _)| archive::Download { path, bytes: 0 }) + .map_err(Into::into) + } else { + // Small nonempty streams still need raw bytes: JSON may replace invalid UTF-8. + let endpoint = url + .clone() + .unwrap_or_else(|| format!("/containers/{id}/exec/{exec_id}/{stream}")); + archive::exec_download(h, &endpoint, &name, stream, deadline, *count) + }; + match saved { + Ok(d) => { + *path = Some(d.path); + *count = Some(d.bytes); + } + Err(e) => { + eprintln!( + "box {id} {stream} archive failed: {e}; command completed, do not replay" + ); + let notice = result.output_notice.get_or_insert_with(String::new); + notice.push_str(&format!( + "{stream} archive unavailable: {}; command completed, do not replay. ", + e.message + )); + } + } + } + } + if result.exec_id.is_none() { + // Legacy services expose only JSON text. Preserve all available text before + // bounding presentation, without claiming bytes discarded upstream exist. + for (stream, text, path, count) in [ + ( + "stdout", + &result.stdout, + &mut result.stdout_path, + &mut result.stdout_bytes, + ), + ( + "stderr", + &result.stderr, + &mut result.stderr_path, + &mut result.stderr_bytes, + ), + ] { + *count = Some(text.len() as u64); + match retention::save("box", &format!("{id}-{stream}"), text.as_bytes()) { + Ok(saved) => *path = Some(saved), + Err(e) => { + let notice = result.output_notice.get_or_insert_with(String::new); + notice.push_str(&format!( + "{stream} archive unavailable: {e}; command completed, do not replay. " + )); + } + } + } + } + let bytes = serde_json::to_vec_pretty(&result)?; + match retention::save("box", id, &bytes) { + Ok(path) => result.output_log = Some(path), + Err(e) => eprintln!( + "box {id} output archive failed: {e}; command completed, full result returned; do not replay" + ), + } + Ok(result) +} +fn checked(h: &str, id: &str, cmd: Value) -> Result { + let r = execute(h, id, cmd, None, 300)?; + if r.exit != 0 || r.timed_out || r.service_truncated { + return Err(error(format!( + "box command failed (exit {}, timeout {}, truncated {}): {}; {}", + r.exit, + r.timed_out, + r.truncated, + r.stderr, + r.output_notice.as_deref().unwrap_or("") + ))); + } + if let Some(path) = r.stdout_path { + return Ok(std::fs::read_to_string(path)?); + } + // Archival failure is not execution failure. Helpers can use complete inline + // text (especially empty mutation output); never parse a cut/binary excerpt. + if r.exec_id.is_some() + && (r.stdout_bytes != Some(r.stdout.len() as u64) || r.stdout.contains('\u{fffd}')) + { + return Err(error(format!( + "command completed; required stdout unavailable; {}; do not replay", + r.output_notice + .as_deref() + .unwrap_or("incomplete inline stdout") + ))); + } + Ok(r.stdout) +} +// The person explicitly approved bounded polling within create/node_box because +// this service has no readiness events. No worker or polling survives the RPC. +#[derive(Clone, Copy)] +pub struct Readiness { + timeout_ns: u64, + step_ns: u64, + max_sleep_ns: u64, +} +const READINESS: Readiness = Readiness { + timeout_ns: 240_000_000_000, + step_ns: 1_000_000_000, + max_sleep_ns: 3_000_000_000, +}; +fn accepted_error(mut e: Error, id: &str, h: &str) -> Error { + e.message = format!( + "box {id} on {h}: {}; continue with box.get, do not retry create", + e.message + ); + e.data = Some(json!({"id":id,"host":h})); + e +} +fn wait_ready(h: &str, mut c: Container, timing: Readiness, deadline: u64) -> Result { + let mut sleep_ns = timing.step_ns; + loop { + match c.state.as_str() { + "running" => return Ok(c), + "failed" => { + // Preserve terminal metadata even if the log endpoint is unavailable. + let tail = archive::build(h, &c.id, deadline); + match tail { + Ok(log) => { + c.build_log_tail = Some(log.text); + c.build_log_path = Some(log.path); + c.build_log_bytes = Some(log.bytes); + c.build_log_service_truncated = Some(log.service_truncated); + c.build_log_notice = log.notice; + } + Err(e) => { + c.build_log_tail = Some(format!("build log unavailable: {}", e.message)) + } + } + return Ok(c); + } + _ => {} + } + let now = http::now(); + if now >= deadline { + return Err(error("readiness timed out after 240 s")); + } + wasi::clocks::monotonic_clock::subscribe_instant( + now.saturating_add(sleep_ns).min(deadline), + ) + .block(); + if http::now() >= deadline { + return Err(error("readiness timed out after 240 s")); + } + c = http::json_until(h, Method::Get, &base(&c.id)?, None, 30, Some(deadline))?; + sleep_ns = sleep_ns + .saturating_add(timing.step_ns) + .min(timing.max_sleep_ns); + } +} +impl BoxPlugin { + pub fn create_box(&self, p: Create) -> Result { + self.create_box_with_wait(p, READINESS) + } + fn create_box_with_wait(&self, p: Create, timing: Readiness) -> Result { + let n = usize::from(p.config.flake.is_some()) + + usize::from(p.config.nixos.is_some()) + + usize::from(p.config.system.is_some()); + if n != 1 || p.ttl_secs == Some(0) { + return Err(error( + "provide exactly one config source and a positive TTL", + )); + } + let h = host(p.host.as_deref())?; + let deadline = http::now().saturating_add(timing.timeout_ns); + let c: Container = http::json_until( + &h, + Method::Post, + "/containers", + Some( + json!({"config":p.config,"ttl_secs":p.ttl_secs,"owner":p.owner.unwrap_or_default()}), + ), + 30, + Some(deadline), + )?; + let id = c.id.clone(); + remember(&id, &h).map_err(|e| accepted_error(e, &id, &h))?; + eprintln!("box {id} accepted on {h}; waiting up to 240 s for readiness"); + wait_ready(&h, c, timing, deadline).map_err(|e| accepted_error(e, &id, &h)) + } +} + +impl BoxPlugin { + /// Create a box and wait up to 240 seconds for running/failed. Timeout errors carry id/host for box.get; never retry create blindly. + pub fn create(&self, p: Create) -> Result { + self.create_box(p) + } + /// List boxes known to one service host. + pub fn list(&self, p: List) -> Result { + http::json( + &host(p.host.as_deref())?, + Method::Get, + "/containers", + None, + 30, + ) + } + /// Fetch current metadata; expired/deleted IDs yield a clear service 404. + pub fn get(&self, p: Id) -> Result { + get(&resolve(&p.id, p.host.as_deref())?, &p.id) + } + /// Fetch and archive the full build log; old services fall back to their tail with a warning. + pub fn log(&self, p: Id) -> Result { + log(&resolve(&p.id, p.host.as_deref())?, &p.id) + } + /// Reset remaining lifetime to ttl_secs from now. + pub fn extend(&self, p: Extend) -> Result { + if p.ttl_secs == 0 { + return Err(error("ttl_secs must be positive")); + } + http::json( + &resolve(&p.id, p.host.as_deref())?, + Method::Post, + &format!("{}/extend", base(&p.id)?), + Some(json!({"ttl_secs":p.ttl_secs})), + 30, + ) + } + /// Destroy a box. Unknown IDs error even though upstream delete is idempotent. + pub fn delete(&self, p: Id) -> Result { + let h = resolve(&p.id, p.host.as_deref())?; + get(&h, &p.id)?; + // Best-effort byte preservation must never make explicit cleanup impossible. + if let Err(e) = log(&h, &p.id) { + eprintln!( + "box {} build log not saved before deletion: {}", + p.id, e.message + ); + } + http::json(&h, Method::Delete, &base(&p.id)?, None, 30) + } + /// Run a shell string or argv command as root inside the box. + pub fn exec(&self, p: Exec) -> Result { + match &p.cmd { + Command::Shell(s) if s.is_empty() => return Err(error("cmd must not be empty")), + Command::Argv(a) if a.is_empty() => return Err(error("argv must not be empty")), + _ => {} + } + let mut result = execute( + &resolve(&p.id, p.host.as_deref())?, + &p.id, + serde_json::to_value(p.cmd)?, + p.stdin, + p.timeout_secs.unwrap_or(60), + )?; + excerpt::apply( + &mut result, + p.inline_bytes.unwrap_or(excerpt::DEFAULT_BYTES), + ); + Ok(result) + } + /// Stream text or a world file to a box, enforcing the service's 64 MiB cap. + pub fn put_file(&self, p: PutFile) -> Result { + let h = resolve(&p.id, p.host.as_deref())?; + let bytes = match (p.text, p.from_vfs) { + (Some(text), None) => { + transfer::put(&h, &p.id, &p.path, &mut text.as_bytes(), text.len() as u64)? + } + (None, Some(path)) => { + let mut f = File::open(path)?; + let len = f.metadata()?.len(); + transfer::put(&h, &p.id, &p.path, &mut f, len)? + } + _ => return Err(error("provide exactly one of text and from_vfs")), + }; + Ok(FileResult { + bytes, + text: None, + to_vfs: None, + }) + } + /// Download into VFS without returning file contents; inline text is capped at 64 KiB. + pub fn get_file(&self, p: GetFile) -> Result { + transfer::get_file(&resolve(&p.id, p.host.as_deref())?, p) + } + /// Tar a world directory and stream it to the box. Merges into to_dir. + pub fn put_tree(&self, p: PutTree) -> Result { + transfer::put_tree(&resolve(&p.id, p.host.as_deref())?, p) + } + /// Stream a tar from a box and unpack into a new world directory. + pub fn get_tree(&self, p: GetTree) -> Result { + transfer::get_tree(&resolve(&p.id, p.host.as_deref())?, p) + } + /// Create a node/npm/git/Chromium/fontconfig box with Playwright environment defaults. + pub fn node_box(&self, p: NodeBox) -> Result { + self.create_box(Create { + config: Config { + flake: None, + nixos: Some(Nixos::Text(NODE_MODULE.into())), + system: None, + }, + ttl_secs: p.ttl_secs, + owner: Some("box.node_box".into()), + host: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn retention_deadline_scales_without_a_byte_cap() { + assert_eq!(archive_budget_ns(Some(0), Some(0)), 60_000_000_000); + assert_eq!(archive_budget_ns(Some(256 * 1024), Some(1)), 62_000_000_000); + assert_eq!(archive_budget_ns(None, Some(0)), 300_000_000_000); + assert_eq!( + archive_budget_ns(Some(u64::MAX), Some(u64::MAX)), + 1_800_000_000_000 + ); + } +} diff --git a/userland/box/src/main.rs b/userland/box/src/main.rs new file mode 100644 index 0000000..37506a8 --- /dev/null +++ b/userland/box/src/main.rs @@ -0,0 +1,12 @@ +//! The world box command. All network operations use standard wasi:http. +use clap::Parser; +fn main() { + let code = match box_cli::cli::run(box_cli::cli::Args::parse()) { + Ok(code) => code, + Err(error) => { + eprintln!("box: {error}"); + 1 + } + }; + std::process::exit(code); +} diff --git a/userland/box/src/retention.rs b/userland/box/src/retention.rs new file mode 100644 index 0000000..bc190ab --- /dev/null +++ b/userland/box/src/retention.rs @@ -0,0 +1,93 @@ +//! Append-only diagnostic files, independent of resumable state. +use std::io::{self, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; +static NEXT: AtomicU64 = AtomicU64::new(0); + +pub fn date() -> String { + let days = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 86400; + date_from_days(days as i64) +} +fn date_from_days(days: i64) -> String { + // Gregorian civil date from Unix days (400-year eras). + let z = days + 719468; + let era = z / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = mp + if mp < 10 { 3 } else { -9 }; + format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2)) +} + +pub fn create(name: &str, extension: &str) -> io::Result<(String, std::fs::File)> { + let dir = format!("/logs/box/{}", date()); + std::fs::create_dir_all(&dir)?; + let name: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let mut path = format!("{dir}/{name}.{extension}"); + loop { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(f) => return Ok((path, f)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + path = format!( + "{dir}/{name}-{}-{}.{extension}", + crate::pid(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + } + Err(e) => return Err(e), + } + } +} + +pub fn save(area: &str, name: &str, bytes: &[u8]) -> io::Result { + let dir = format!("/logs/{area}/{}", date()); + std::fs::create_dir_all(&dir)?; + loop { + let path = format!( + "{dir}/{name}-{}-{}.log", + crate::pid(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut f) => { + f.write_all(bytes)?; + return Ok(path); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn archive_calendar_dates() { + assert_eq!(date_from_days(0), "1970-01-01"); + assert_eq!(date_from_days(19782), "2024-02-29"); + assert_eq!(date_from_days(20635), "2026-07-01"); + } +} diff --git a/userland/box/src/transfer.rs b/userland/box/src/transfer.rs new file mode 100644 index 0000000..17ecc41 --- /dev/null +++ b/userland/box/src/transfer.rs @@ -0,0 +1,379 @@ +//! Bounded streaming transfers. Payloads stay in HTTP/VFS, not RPC responses. +use super::*; + +pub fn file_path(id: &str, path: &str) -> Result { + if !path.starts_with('/') || path == "/" { + return Err(error("expected an absolute file path inside the box")); + } + let mut u = url::Url::parse("http://box.invalid").map_err(|e| error(e.to_string()))?; + { + let mut segments = u.path_segments_mut().map_err(|_| error("bad file path"))?; + segments.clear().push("containers").push(id).push("files"); + for s in path[1..].split('/') { + segments.push(s); + } + } + Ok(u.path().into()) +} +pub fn put(h: &str, id: &str, path: &str, source: &mut dyn Read, len: u64) -> Result { + if len > MAX_FILE { + return Err(error("service file limit is 64 MiB; split larger files")); + } + let mut body = http::request( + h, + Method::Put, + &file_path(id, path)?, + Some((source, len)), + 300, + )?; + let ok: OkResult = serde_json::from_reader(&mut body)?; + if !ok.ok { + return Err(error("service file write did not succeed")); + } + Ok(len) +} +fn size(h: &str, id: &str, path: &str) -> Result { + // stat only regular files: service otherwise returns directory listings or + // can silently truncate at 64 MiB. Keep flags and user paths separate. + let out = checked( + h, + id, + json!([ + "sh", + "-c", + "[ -f \"$1\" ] && stat -Lc %s -- \"$1\"", + "sh", + path + ]), + )?; + let n: u64 = out + .trim() + .parse() + .map_err(|_| error("invalid file size from box"))?; + if n > MAX_FILE { + return Err(error("service file limit is 64 MiB; split larger files")); + } + Ok(n) +} +fn download(h: &str, id: &str, path: &str, out: &mut dyn Write, expected: u64) -> Result { + let mut body = http::request(h, Method::Get, &file_path(id, path)?, None, 300)?; + let n = std::io::copy(&mut (&mut body).take(MAX_FILE + 1), out)?; + if n != expected { + return Err(error(format!( + "file changed or response truncated: expected {expected} bytes, got {n}" + ))); + } + Ok(n) +} + +/// Private disk spools, removed on both success and error. Not persistent state. +struct Spool { + dir: std::path::PathBuf, +} +impl Spool { + fn new() -> Result { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let dir = std::path::PathBuf::from(format!( + "/var/lib/box/transfers/{}-{}", + crate::pid(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(dir.parent().ok_or_else(|| error("no spool parent"))?)?; + std::fs::create_dir(&dir)?; + Ok(Self { dir }) + } + fn path(&self) -> std::path::PathBuf { + self.dir.join("archive.tar") + } +} +impl Drop for Spool { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +pub fn get_file(h: &str, p: GetFile) -> Result { + let len = size(h, &p.id, &p.path)?; + if let Some(dest) = &p.to_vfs { + let target = Path::new(dest); + let parent = target + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent)?; + let temp = parent.join(format!(".box-download-{}", crate::pid())); + // Use create_new: never truncate an unrelated temporary file. + let mut f = File::options().write(true).create_new(true).open(&temp)?; + let result = (|| -> Result<()> { + download(h, &p.id, &p.path, &mut f, len)?; + f.flush()?; + drop(f); + std::fs::rename(&temp, target)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result?; + Ok(FileResult { + bytes: len, + text: None, + to_vfs: p.to_vfs, + }) + } else { + if len > 65536 { + return Err(error( + "inline file limit is 64 KiB; use to_vfs to stream larger files", + )); + } + let mut data = Vec::new(); + download(h, &p.id, &p.path, &mut data, len)?; + Ok(FileResult { + bytes: len, + text: Some( + String::from_utf8(data).map_err(|_| error("file is not UTF-8; use to_vfs"))?, + ), + to_vfs: None, + }) + } +} +fn quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} +fn dir(s: &str) -> Result<()> { + if !s.starts_with('/') { + return Err(error("box directory must be absolute")); + } + Ok(()) +} +fn remote_tmp() -> String { + format!("/tmp/sausage-box-{}.tar", crate::pid()) +} +fn cleanup(h: &str, id: &str, path: &str) { + if let Err(e) = checked(h, id, json!(["rm", "-f", "--", path])) { + eprintln!("box transfer cleanup: {e}"); + } +} + +// tar::Header::set_metadata panics on wasm32-wasip2. Construct portable +// headers explicitly; WASI does not expose Unix executable mode bits. +fn append_tree(tar: &mut tar::Builder, source: &Path, name: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(source)?; + let payload = if metadata.is_file() { + metadata.len() + } else { + 0 + }; + if tar + .get_ref() + .metadata()? + .len() + .saturating_add(payload) + .saturating_add(4096) + > MAX_FILE + { + return Err(error("tree tar exceeds 64 MiB service limit")); + } + let mut header = tar::Header::new_gnu(); + header.set_mtime( + metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0), + ); + header.set_uid(0); + header.set_gid(0); + if metadata.is_dir() { + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, name, std::io::empty())?; + let mut entries = std::fs::read_dir(source)?.collect::>>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + append_tree(tar, &entry.path(), &name.join(entry.file_name()))?; + } + } else if metadata.is_file() { + if metadata.len() > MAX_FILE { + return Err(error("tree exceeds 64 MiB service limit")); + } + header.set_entry_type(tar::EntryType::Regular); + header.set_size(metadata.len()); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, name, File::open(source)?)?; + } else if metadata.file_type().is_symlink() { + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + tar.append_link(&mut header, name, std::fs::read_link(source)?)?; + } else { + return Err(error(format!( + "cannot copy special file {}", + source.display() + ))); + } + Ok(()) +} + +pub fn put_tree(h: &str, p: PutTree) -> Result { + dir(&p.to_dir)?; + if !Path::new(&p.from_vfs_dir).is_dir() { + return Err(error("from_vfs_dir is not a directory")); + } + let spool = Spool::new()?; + let f = File::create(spool.path())?; + let mut tar = tar::Builder::new(f); + tar.follow_symlinks(false); + append_tree(&mut tar, Path::new(&p.from_vfs_dir), Path::new("."))?; + let mut output = tar.into_inner()?; + output.flush()?; + drop(output); + let mut f = File::open(spool.path())?; + let len = f.metadata()?.len(); + let remote = remote_tmp(); + let result = (|| -> Result<()> { + put(h, &p.id, &remote, &mut f, len)?; + checked( + h, + &p.id, + json!([ + "sh", + "-c", + format!( + "mkdir -p -- {0} && tar -xf {1} -C {0}", + quote(&p.to_dir), + quote(&remote) + ) + ]), + )?; + Ok(()) + })(); + cleanup(h, &p.id, &remote); + result?; + Ok(OkResult { ok: true }) +} +// tar's generic unpack attempts unsupported Unix filesystem operations on +// WASI. Copy contents explicitly, avoiding chmod/utime. Symlink creation is +// unsupported by the world VFS and must fail without publishing a partial tree. +fn unpack_tree(file: File, root: &Path) -> Result<()> { + let mut archive = tar::Archive::new(file); + let mut hardlinks = Vec::new(); + let mut expanded = 0u64; + for entry in archive.entries()? { + let mut entry = entry?; + let name = entry.path()?.into_owned(); + let safe = |p: &Path| { + p.components().all(|c| { + matches!( + c, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) + }; + if !safe(&name) { + return Err(error("archive has a path outside the destination")); + } + let path = root.join(&name); + // Do not accidentally write through an earlier archive symlink. + for parent in name.ancestors().skip(1) { + if std::fs::symlink_metadata(root.join(parent)) + .is_ok_and(|m| m.file_type().is_symlink()) + { + return Err(error("archive writes through a symlink directory")); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let kind = entry.header().entry_type(); + if kind.is_dir() { + std::fs::create_dir_all(&path)?; + } else if kind.is_file() { + expanded = expanded.saturating_add(entry.size()); + if expanded > MAX_FILE { + return Err(error("expanded tree exceeds 64 MiB transfer limit")); + } + let mut out = File::options().write(true).create_new(true).open(&path)?; + std::io::copy(&mut entry, &mut out)?; + } else if kind.is_symlink() { + return Err(error( + "world VFS does not support creating symlinks; export a dereferenced tar/tree from the box instead", + )); + } else if kind.is_hard_link() { + let target = entry + .link_name()? + .ok_or_else(|| error("archive hardlink has no target"))? + .into_owned(); + if !safe(&target) { + return Err(error("archive hardlink target is outside destination")); + } + hardlinks.push((path, root.join(target))); + } else { + return Err(error("archive contains an unsupported special file")); + } + } + // Materialize hardlinks as independent files: VFS has no hardlink contract. + for (path, target) in hardlinks { + let metadata = std::fs::symlink_metadata(&target)?; + if !metadata.is_file() { + return Err(error("archive hardlink target is not a regular file")); + } + expanded = expanded.saturating_add(metadata.len()); + if expanded > MAX_FILE { + return Err(error("expanded tree exceeds 64 MiB transfer limit")); + } + let mut out = File::options().write(true).create_new(true).open(path)?; + std::io::copy(&mut File::open(target)?, &mut out)?; + } + Ok(()) +} + +pub fn get_tree(h: &str, p: GetTree) -> Result { + dir(&p.from_dir)?; + let dest = Path::new(&p.to_vfs_dir); + if dest.try_exists()? { + return Err(error("to_vfs_dir already exists; choose a new directory")); + } + let spool = Spool::new()?; + let remote = remote_tmp(); + let result = (|| -> Result<()> { + checked( + h, + &p.id, + json!(["tar", "-cf", remote, "-C", p.from_dir, "."]), + )?; + let len = size(h, &p.id, &remote)?; + let mut f = File::create(spool.path())?; + download(h, &p.id, &remote, &mut f, len)?; + drop(f); + // Stage alongside destination so rename is atomic and cannot cross mounts. + let parent = dest + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent)?; + let staging = parent.join(format!(".box-tree-{}", crate::pid())); + std::fs::create_dir(&staging)?; + let unpack = (|| -> Result<()> { + unpack_tree(File::open(spool.path())?, &staging)?; + // Recheck to avoid accidentally replacing an existing empty directory. + if dest.try_exists()? { + return Err(error("destination appeared during transfer")); + } + std::fs::rename(&staging, dest)?; + Ok(()) + })(); + if unpack.is_err() { + let _ = std::fs::remove_dir_all(staging); + } + unpack + })(); + cleanup(h, &p.id, &remote); + result?; + Ok(OkResult { ok: true }) +} diff --git a/userland/box/test.nu b/userland/box/test.nu new file mode 100644 index 0000000..2b623c8 --- /dev/null +++ b/userland/box/test.nu @@ -0,0 +1,66 @@ +#!/usr/bin/env nu +# Real service acceptance tests. Every created box is removed, including failures. +def check [condition: bool, message: string] { + if not $condition { error make {msg: $message} } + print $'ok - ($message)' +} +def call [component: path, root: path, args: list] { + ^wasmtime run -S http -S cli-exit-with-code=y --dir $'($root)::/' $component ...$args | complete +} +def require [] { + let result = $in + if $result.exit_code != 0 { error make {msg: $'box failed: ($result.stderr)'} } + $result +} +def main [component: path = '/home/surma/src/gitea.surma.technology/surma/sausage/target/p1-tools/wasm32-wasip2/release/box.wasm'] { + let component = ($component | path expand) + let root = (^mktemp -d /tmp/box-test-XXXXXXXX | str trim) + mkdir ($root | path join project sub) + '{"scripts":{"test":"node sub/test.js"}}' | save ($root | path join project package.json) + 'const fs = require("node:fs"); require("node:assert/strict").equal(2 + 2, 4); fs.writeFileSync("result.txt", "npm test passed\n");' | save ($root | path join project sub test.js) + 'binary-safe file payload' | save ($root | path join input.txt) + let created = (call $component $root [create --ttl 600]) + let id = if $created.stdout =~ 'tc-[a-z0-9]{6}' { $created.stdout | str trim } else { $created.stderr | parse --regex '(?Ptc-[a-z0-9]{6})' | get id | first | default '' } + if $id == '' { error make {msg: $'create returned no recoverable id: ($created.stderr); test files: ($root)'} } + let outcome = try { + $created | require | ignore + check ($id =~ '^tc-[a-z0-9]{6}$') 'create returns id' + let inventory = (call $component $root [ls] | require) + check ($inventory.stdout | str contains $id) 'ls contains box' + let exec = (call $component $root [exec $id -- sh -c 'printf hello; printf problem >&2; exit 7']) + check ($exec.exit_code == 7 and $exec.stdout == 'hello' and ($exec.stderr | str contains 'problem')) 'exec preserves both streams and exit 7' + call $component $root [put $id /input.txt /tmp/file.txt] | require | ignore + call $component $root [get $id /tmp/file.txt /returned.txt] | require | ignore + check ((open --raw ($root | path join returned.txt)) == (open --raw ($root | path join input.txt))) 'file put/get round trip' + call $component $root [put $id /project /work] | require | ignore + call $component $root [get $id /work /tree] | require | ignore + check ((open --raw ($root | path join tree sub test.js)) == (open --raw ($root | path join project sub test.js))) 'tree round trip retains subfolder' + let npm = (call $component $root [exec $id --timeout 120 -- sh -c 'cd /work && npm test'] | require) + check ($npm.stdout | str contains 'node sub/test.js') 'npm test runs in box' + call $component $root [get $id /work/result.txt /result.txt] | require | ignore + check ((open --raw ($root | path join result.txt)) == "npm test passed\n") 'Node result returns to world' + let large = (call $component $root [exec $id -- node -e 'process.stdout.write(Buffer.alloc(1200003,255)); process.stderr.write("raw stderr");'] | require) + check (($large.stdout | str length --utf-8-bytes) < 16600) 'large binary inline output stays bounded' + let archive = ($large.stderr | parse --regex 'box stdout archive: (?P[^\n]+)' | get path | first) + let bytes = (open --raw ($root | path join ($archive | str trim --left --char '/'))) + check (($bytes | bytes length) == 1200003) 'full binary archive retains byte total' + let repeated = (call $component $root [exec $id -- sh -c 'printf collision-check'] | require) + check ($repeated.stdout == 'collision-check') 'later exec returns output' + check ((open --raw ($root | path join ($archive | str trim --left --char '/'))) == $bytes) 'later archive leaves earlier output intact' + let log = (call $component $root [logs $id] | require) + check ($log.stderr | str contains 'box build archive:') 'logs retains build archive' + let wit = (^wasm-tools component wit $component | complete) + check ($wit.exit_code == 0) 'component WIT inspection' + print $wit.stdout + let imports = ($wit.stdout | lines | where {|line| $line | str trim | str starts-with 'import ' }) + check ($imports | all {|line| $line | str trim | str starts-with 'import wasi:' }) 'component imports only WASI interfaces' + null + } catch {|failure| $failure } + let removed = (call $component $root [rm $id]) + print $removed.stderr + check ($removed.exit_code == 0) $'rm removes ($id)' + let after = (call $component $root [ls] | require) + check (not ($after.stdout | str contains $id)) 'removed box absent from ls' + if $outcome != null { error make {msg: $'acceptance failed: ($outcome); retained evidence: ($root)'} } + print $'PASS; world files and archives: ($root)' +}