374 lines
13 KiB
Rust
374 lines
13 KiB
Rust
//! Core-module tests for preview1 filesystem routing and real userland programs.
|
|
use sausage_vfs::{
|
|
preview1::{self, State},
|
|
*,
|
|
};
|
|
use std::{
|
|
hash::{Hash, Hasher},
|
|
path::Path,
|
|
sync::{Arc, OnceLock},
|
|
};
|
|
use wasmtime::{Engine, Linker, Module, Store};
|
|
|
|
fn engine() -> &'static Engine {
|
|
static E: OnceLock<Engine> = OnceLock::new();
|
|
E.get_or_init(Engine::default)
|
|
}
|
|
fn module(path: &Path) -> Module {
|
|
let bytes =
|
|
std::fs::read(path).unwrap_or_else(|e| panic!("required guest {}: {e}", path.display()));
|
|
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
|
bytes.hash(&mut hash);
|
|
engine().precompile_compatibility_hash().hash(&mut hash);
|
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("preview1-guests");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let cache = dir.join(format!("{:x}.cwasm", hash.finish()));
|
|
if !cache.exists() {
|
|
std::fs::write(&cache, engine().precompile_module(&bytes).unwrap()).unwrap();
|
|
}
|
|
// This cache contains only modules compiled locally by this test with this engine.
|
|
unsafe { Module::deserialize_file(engine(), cache) }.unwrap()
|
|
}
|
|
async fn run(module: &Module, root: Arc<dyn Layer>, cwd: &str, args: &[&str]) -> (String, String) {
|
|
let (stdout, mut out) = tokio::io::duplex(1024 * 1024);
|
|
let (stderr, mut err) = tokio::io::duplex(1024 * 1024);
|
|
let mut linker = Linker::new(engine());
|
|
preview1::add_to_linker(&mut linker, |s: &mut State| s).unwrap();
|
|
let mut store = Store::new(
|
|
engine(),
|
|
State::new(
|
|
root,
|
|
cwd,
|
|
args.iter().map(|s| (*s).into()).collect(),
|
|
vec![],
|
|
Box::new(tokio::io::empty()),
|
|
Box::new(stdout),
|
|
Box::new(stderr),
|
|
),
|
|
);
|
|
let instance = linker.instantiate_async(&mut store, module).await.unwrap();
|
|
let start = instance
|
|
.get_typed_func::<(), ()>(&mut store, "_start")
|
|
.unwrap();
|
|
let result = start.call_async(&mut store, ()).await;
|
|
drop(store);
|
|
use tokio::io::AsyncReadExt;
|
|
let mut stdout = String::new();
|
|
out.read_to_string(&mut stdout).await.unwrap();
|
|
let mut stderr = String::new();
|
|
err.read_to_string(&mut stderr).await.unwrap();
|
|
if let Err(e) = result {
|
|
assert_eq!(
|
|
e.downcast_ref::<wasmtime_wasi::I32Exit>().map(|e| e.0),
|
|
Some(0),
|
|
"guest failed: {e:?}\n{stderr}"
|
|
);
|
|
}
|
|
(stdout, stderr)
|
|
}
|
|
fn wat(path: &str, body: &str) -> Module {
|
|
Module::new(engine(), format!(r#"(module
|
|
(import "wasi_snapshot_preview1" "path_filestat_get" (func $stat (param i32 i32 i32 i32 i32) (result i32)))
|
|
(memory (export "memory") 1)
|
|
(data (i32.const 0) "{path}")
|
|
(func (export "_start") {body}))"#)).unwrap()
|
|
}
|
|
#[tokio::test]
|
|
async fn cwd_and_parent_across_mounts() {
|
|
let base = Arc::new(Memory::default());
|
|
base.mkdir("/work").await.unwrap();
|
|
let shared = Arc::new(Memory::default());
|
|
shared.mkdir("/inner").await.unwrap();
|
|
base.open(
|
|
"/value",
|
|
Open {
|
|
create: true,
|
|
write: true,
|
|
..Open::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let file = shared
|
|
.open(
|
|
"/value",
|
|
Open {
|
|
create: true,
|
|
write: true,
|
|
..Open::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
file.write(0, b"hello".to_vec()).await.unwrap();
|
|
let mut mounts = Mounts::default();
|
|
mounts.mount("/", base, false);
|
|
mounts.mount("/shared", shared, false);
|
|
let mounts = Arc::new(mounts);
|
|
let guest = wat(
|
|
"../shared/value",
|
|
"i32.const 3 i32.const 1 i32.const 0 i32.const 15 i32.const 64 call $stat if unreachable end i32.const 96 i64.load i64.const 5 i64.ne if unreachable end",
|
|
);
|
|
run(&guest, mounts.clone(), "/work", &["probe"]).await;
|
|
let parent = wat(
|
|
"../../value",
|
|
"i32.const 3 i32.const 1 i32.const 0 i32.const 11 i32.const 64 call $stat if unreachable end i32.const 96 i64.load i64.const 0 i64.ne if unreachable end",
|
|
);
|
|
run(&parent, mounts, "/shared/inner", &["probe"]).await;
|
|
println!("PASS cwd and .. across mounts");
|
|
}
|
|
#[tokio::test]
|
|
async fn missing_file_errno() {
|
|
let guest = wat(
|
|
"../shared/value",
|
|
"i32.const 3 i32.const 1 i32.const 0 i32.const 15 i32.const 64 call $stat i32.const 44 i32.ne if unreachable end",
|
|
);
|
|
run(&guest, Arc::new(Memory::default()), "/work", &["probe"]).await;
|
|
println!("PASS missing path returns NOENT=44");
|
|
}
|
|
#[tokio::test]
|
|
async fn poll_preserves_input_and_returns_clock() {
|
|
use preview1::{types::*, wasi_snapshot_preview1::WasiSnapshotPreview1};
|
|
use wiggle::{GuestMemory, GuestPtr};
|
|
let mut state = State::new(
|
|
Arc::new(Memory::default()),
|
|
"/",
|
|
vec![],
|
|
vec![],
|
|
Box::new(&b"x"[..]),
|
|
Box::new(tokio::io::sink()),
|
|
Box::new(tokio::io::sink()),
|
|
);
|
|
let mut bytes = [0; 256];
|
|
let mut memory = GuestMemory::Unshared(&mut bytes);
|
|
let sub = GuestPtr::new(0);
|
|
let event = GuestPtr::new(64);
|
|
memory
|
|
.write(
|
|
sub,
|
|
Subscription {
|
|
userdata: 7,
|
|
u: SubscriptionU::FdRead(SubscriptionFdReadwrite {
|
|
file_descriptor: 0.into(),
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.poll_oneoff(&mut memory, sub, event, 1).await.unwrap(),
|
|
1
|
|
);
|
|
assert_eq!(memory.read(event).unwrap().userdata, 7);
|
|
let iov = GuestPtr::new(128);
|
|
memory
|
|
.write(
|
|
iov,
|
|
Iovec {
|
|
buf: GuestPtr::new(192),
|
|
buf_len: 100,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
state
|
|
.fd_read(&mut memory, 0.into(), iov.as_array(1))
|
|
.await
|
|
.is_err()
|
|
);
|
|
memory
|
|
.write(
|
|
iov,
|
|
Iovec {
|
|
buf: GuestPtr::new(192),
|
|
buf_len: 1,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
state
|
|
.fd_read(&mut memory, 0.into(), iov.as_array(1))
|
|
.await
|
|
.unwrap(),
|
|
1
|
|
);
|
|
assert_eq!(memory.read(GuestPtr::<u8>::new(192)).unwrap(), b'x');
|
|
memory
|
|
.write(
|
|
sub,
|
|
Subscription {
|
|
userdata: 8,
|
|
u: SubscriptionU::Clock(SubscriptionClock {
|
|
id: Clockid::Monotonic,
|
|
timeout: 0,
|
|
precision: 0,
|
|
flags: Subclockflags::SUBSCRIPTION_CLOCK_ABSTIME,
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.poll_oneoff(&mut memory, sub, event, 1).await.unwrap(),
|
|
1
|
|
);
|
|
assert_eq!(memory.read(event).unwrap().type_, Eventtype::Clock);
|
|
memory
|
|
.write(
|
|
sub,
|
|
Subscription {
|
|
userdata: 9,
|
|
u: SubscriptionU::FdWrite(SubscriptionFdReadwrite {
|
|
file_descriptor: 1.into(),
|
|
}),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.poll_oneoff(&mut memory, sub, event, 1).await.unwrap(),
|
|
1
|
|
);
|
|
assert_eq!(memory.read(event).unwrap().type_, Eventtype::FdWrite);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn userland_files() {
|
|
let bundle = std::env::var_os("SAUSAGE_USERLAND")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/userland/bundle"));
|
|
let qjs = module(&bundle.join("bin/qjs.wasm"));
|
|
let root = Arc::new(Memory::default());
|
|
let script = r#"
|
|
if (os.mkdir('/work') !== 0) throw Error('mkdir');
|
|
let f = std.open('/work/input', 'w'); f.puts('hello preview1\n'); f.close();
|
|
f = std.open('/work/input', 'r'); let text = f.readAsString(); f.close();
|
|
let out = std.open('/work/copy', 'w'); out.puts(text); out.close();
|
|
f = std.open('/work/copy', 'r'); console.log(f.readAsString().trim()); f.close();
|
|
let [entries, error] = os.readdir('/work');
|
|
if (error) throw Error('readdir');
|
|
console.log(entries.sort().join(','));
|
|
if (os.stat('/work/copy')[0].size !== 15) throw Error('stat');
|
|
if (os.remove('/work/copy') !== 0) throw Error('remove');
|
|
"#;
|
|
let (out, _) = run(&qjs, root.clone(), "/", &["qjs", "--std", "-e", script]).await;
|
|
assert!(
|
|
out.starts_with("hello preview1\n") && out.contains("copy") && out.contains("input"),
|
|
"{out}"
|
|
);
|
|
assert_eq!(
|
|
root.stat("/work/copy", true).await.unwrap_err().kind(),
|
|
std::io::ErrorKind::NotFound
|
|
);
|
|
println!("PASS QuickJS mkdir create copy read stat readdir remove");
|
|
let script = b"let f = std.open('input', 'r'); let text = f.readAsString(); f.close(); let out = std.open('js-output', 'w'); out.puts(text.toUpperCase()); out.close(); console.log('QuickJS files ok');";
|
|
let file = root
|
|
.open(
|
|
"/work/check.mjs",
|
|
Open {
|
|
create: true,
|
|
write: true,
|
|
..Open::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
file.write(0, script.to_vec()).await.unwrap();
|
|
let (out, _) = run(
|
|
&qjs,
|
|
root.clone(),
|
|
"/work",
|
|
&["qjs", "--std", "--module", "check.mjs"],
|
|
)
|
|
.await;
|
|
assert!(out.contains("QuickJS files ok"));
|
|
let output = root.open("/work/js-output", Open::default()).await.unwrap();
|
|
assert_eq!(output.read(0, 100).await.unwrap(), b"HELLO PREVIEW1\n");
|
|
println!("PASS QuickJS reads and writes files");
|
|
}
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn esbuild_bundle() {
|
|
if std::env::var_os("SAUSAGE_NIX_CHECK").is_some() {
|
|
eprintln!("SKIP preview1::esbuild_bundle: the removed spike fixture requires npm downloads and native Go tooling");
|
|
return;
|
|
}
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../spikes/s6-esbuild");
|
|
for name in ["app.tsx", "check.mjs", "package.json", "package-lock.json"] {
|
|
std::fs::copy(fixture.join(name), dir.path().join(name)).unwrap();
|
|
}
|
|
assert!(
|
|
std::process::Command::new("npm")
|
|
.args(["ci", "--ignore-scripts", "--no-audit", "--no-fund"])
|
|
.current_dir(dir.path())
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
);
|
|
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/esbuild/source");
|
|
let programs = Path::new(env!("CARGO_TARGET_TMPDIR")).join("preview1-fixtures");
|
|
std::fs::create_dir_all(&programs).unwrap();
|
|
for (name, wasm) in [("esbuild-native", false), ("esbuild.wasm", true)] {
|
|
let mut build = std::process::Command::new("go");
|
|
build
|
|
.current_dir(&source)
|
|
.env("GOPROXY", "off")
|
|
.env("GOSUMDB", "off")
|
|
.env("GOMAXPROCS", "2")
|
|
.args([
|
|
"build",
|
|
"-p",
|
|
"2",
|
|
"-mod=vendor",
|
|
"-trimpath",
|
|
"-ldflags=-s -w",
|
|
"-o",
|
|
])
|
|
.arg(programs.join(name))
|
|
.arg("./cmd/esbuild");
|
|
if wasm {
|
|
build.env("GOOS", "wasip1").env("GOARCH", "wasm");
|
|
}
|
|
assert!(build.status().unwrap().success(), "build {name}");
|
|
}
|
|
let flags = [
|
|
"app.tsx",
|
|
"--bundle",
|
|
"--minify",
|
|
"--format=iife",
|
|
"--platform=browser",
|
|
"--jsx=automatic",
|
|
"--jsx-import-source=preact",
|
|
"--legal-comments=none",
|
|
"--log-level=error",
|
|
];
|
|
assert!(
|
|
std::process::Command::new(programs.join("esbuild-native"))
|
|
.current_dir(dir.path())
|
|
.args(flags)
|
|
.arg("--outfile=native.js")
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
);
|
|
let root = Arc::new(Host::new(dir.path(), Process::default()).unwrap());
|
|
let guest = module(&programs.join("esbuild.wasm"));
|
|
let mut args = vec!["esbuild"];
|
|
args.extend(flags);
|
|
args.push("--outfile=host.js");
|
|
run(&guest, root, "/", &args).await;
|
|
assert_eq!(
|
|
std::fs::read(dir.path().join("native.js")).unwrap(),
|
|
std::fs::read(dir.path().join("host.js")).unwrap()
|
|
);
|
|
let render = std::process::Command::new("node")
|
|
.current_dir(dir.path())
|
|
.args(["check.mjs", "host.js"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
render.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&render.stderr)
|
|
);
|
|
println!("{}", String::from_utf8_lossy(&render.stdout));
|
|
println!("PASS esbuild through sausage-vfs: byte-identical to native");
|
|
}
|