Merge v3-t40: world box CLI, port of v2's box plugin (T40, P4)

This commit is contained in:
2026-11-07 10:46:30 +00:00
13 changed files with 2327 additions and 1 deletions
+22
View File
@@ -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"
+1 -1
View File
@@ -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]
+16
View File
@@ -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 }
+118
View File
@@ -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/<id>`. 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/<id>-<exec>.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.
+237
View File
@@ -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<u64>,
) -> Result<Download> {
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<Option<Download>> {
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<u64>,
) -> Result<Option<Download>> {
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::<u64>().ok()?,
end.parse::<u64>().ok()?,
size.parse::<u64>().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<crate::Log> {
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<crate::Log> {
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());
}
}
}
+264
View File
@@ -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<String>,
#[command(subcommand)]
pub command: Operation,
}
#[derive(Subcommand, Debug)]
pub enum Operation {
Create {
#[arg(long)]
flake: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
ttl: Option<u32>,
},
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<String>,
},
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<i32> {
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"),
}
}
}
+108
View File
@@ -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));
}
}
+265
View File
@@ -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<Option<Box<dyn FnMut()>>> = 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<u64>) -> 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<String>,
stream: Option<wasi::io::streams::InputStream>,
body: Option<IncomingBody>,
deadline: Option<u64>,
}
impl Read for Incoming {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<Incoming> {
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<u64>,
) -> Result<Incoming> {
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<u64>,
range: Option<&str>,
allow_missing: bool,
) -> Result<Incoming> {
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<T: serde::de::DeserializeOwned>(
host: &str,
method: Method,
path: &str,
value: Option<serde_json::Value>,
secs: u32,
) -> Result<T> {
json_until(host, method, path, value, secs, None)
}
pub fn json_until<T: serde::de::DeserializeOwned>(
host: &str,
method: Method,
path: &str,
value: Option<serde_json::Value>,
secs: u32,
deadline: Option<u64>,
) -> Result<T> {
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,
)?)?)
}
+746
View File
@@ -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<serde_json::Value>,
}
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<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
error(e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
error(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, Error>;
fn error(s: impl Into<String>) -> 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<String>),
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum Nixos {
Text(String),
Options(std::collections::BTreeMap<String, Value>),
}
/// Exactly one Nix configuration source.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Config {
/// Flake reference, optionally #configuration (default container).
flake: Option<String>,
/// NixOS module text or JSON option object.
nixos: Option<Nixos>,
/// Already built /nix/store system path on the service host.
system: Option<String>,
}
/// 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<u32>,
/// Informational owner label.
owner: Option<String>,
/// Service hostname or hostname:port, default citadel:8093.
host: Option<String>,
}
/// Select a service for listing boxes.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct List {
/// Hostname or hostname:port; default citadel:8093.
host: Option<String>,
}
/// 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<String>,
}
/// 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<String>,
/// Bounded build-log tail when creation fails; full available bytes are at build_log_path.
#[serde(default)]
build_log_tail: Option<String>,
/// Permanent archive of the build diagnostic returned by the service.
#[serde(default)]
build_log_path: Option<String>,
/// Exact archived byte count, not a total for old-service tails.
#[serde(default)]
build_log_bytes: Option<u64>,
/// True when the legacy endpoint was used and may have returned only a tail.
#[serde(default)]
build_log_service_truncated: Option<bool>,
/// Inline-tail byte range or compatibility warning, if applicable.
#[serde(default)]
build_log_notice: Option<String>,
}
/// Service inventory.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Containers {
/// Containers currently known to this host.
containers: Vec<Container>,
}
/// 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<String>,
}
/// 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<String>,
/// Default 60, maximum 1800 seconds.
timeout_secs: Option<u32>,
/// Inline UTF-8 byte budget per stream (default 16384), excluding the omission marker. Zero omits stream text. Full archives are unaffected.
inline_bytes: Option<u32>,
/// Override remembered host.
host: Option<String>,
}
/// 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<String>,
/// Service execution identifier (new services only).
#[serde(default)]
exec_id: Option<String>,
/// Exact raw stdout total on new services; available UTF-8 bytes on old services (which may have lost output).
#[serde(default)]
stdout_bytes: Option<u64>,
/// Exact raw stderr total on new services; available UTF-8 bytes on old services (which may have lost output).
#[serde(default)]
stderr_bytes: Option<u64>,
/// Service download reference when stdout exceeded the inline limit.
#[serde(default)]
stdout_url: Option<String>,
/// Service download reference when stderr exceeded the inline limit.
#[serde(default)]
stderr_url: Option<String>,
/// Permanent full raw stdout archive (available UTF-8 text on old services).
#[serde(default)]
stdout_path: Option<String>,
/// Permanent full raw stderr archive (available UTF-8 text on old services).
#[serde(default)]
stderr_path: Option<String>,
/// 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<String>,
}
/// 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<String>,
}
/// 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<String>,
/// World file to stream, including binary data, at most 64 MiB.
from_vfs: Option<String>,
/// Override remembered host.
host: Option<String>,
}
/// 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<String>,
/// Override remembered host.
host: Option<String>,
}
/// 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<String>,
/// Destination world path, if any.
to_vfs: Option<String>,
}
/// 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<String>,
}
/// 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<String>,
}
/// Ready-to-use JavaScript and Chromium container.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct NodeBox {
/// Lifetime in seconds; service default 3600.
ttl_secs: Option<u32>,
}
/// Disposable build/test boxes. No background workers or startup side effects.
pub struct BoxPlugin;
fn host(input: Option<&str>) -> Result<String> {
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<String> {
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<String> {
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<Container> {
http::json(h, Method::Get, &base(id)?, None, 30)
}
fn log(h: &str, id: &str) -> Result<Log> {
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<u64>, stderr: Option<u64>) -> 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<String>, secs: u32) -> Result<Executed> {
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<String> {
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<Container> {
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<Container> {
self.create_box_with_wait(p, READINESS)
}
fn create_box_with_wait(&self, p: Create, timing: Readiness) -> Result<Container> {
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<Container> {
self.create_box(p)
}
/// List boxes known to one service host.
pub fn list(&self, p: List) -> Result<Containers> {
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<Container> {
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> {
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<Container> {
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<OkResult> {
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<Executed> {
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<FileResult> {
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<FileResult> {
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<OkResult> {
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<OkResult> {
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<Container> {
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
);
}
}
+12
View File
@@ -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);
}
+93
View File
@@ -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<String> {
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");
}
}
+379
View File
@@ -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<String> {
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<u64> {
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<u64> {
// 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<u64> {
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<Self> {
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<FileResult> {
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<File>, 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::<std::io::Result<Vec<_>>>()?;
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<OkResult> {
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<OkResult> {
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 })
}
+66
View File
@@ -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<string>] {
^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 '(?P<id>tc-[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<path>[^\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)'
}