Files
sausage/vfs/tests/preview1.rs
T

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");
}