AtlatestRepositorysigil-crypto
sigil-crypto / tree / test / wasmrun-wasm-test.mjs
1
#!/usr/bin/env node2
//3
// sigil-crypto wasm differential test.4
//5
// 1. Builds test/wasm for the host ('host), static musl ('static) and wasm32-wasi with6
// native codegen ('web config), unless SIGIL_PROBE_SKIP_BUILD=1.7
// 2. Runs the host bundle and the wasm module (in Node with a minimal WASI8
// shim) and requires their outputs to match line for line, except lines9
// starting "target:", which must show the Mbed TLS procedures working on10
// the host and refusing with "not available on wasm" on wasm.11
// 3. Checks every line against its expected value, so two identical wrong12
// outputs cannot pass.13
// 4. Drift gate: reads native/crypto.c, requires the wasm stub list to name14
// exactly the Mbed TLS natives the host registers, then calls each one15
// in the wasm module through the eval ABI and requires the refusal.16
//17
// Run from anywhere: node test/wasm/run-wasm-test.mjs18
// Needs: sigil on PATH, guix (for binaryen), node.19
// Exit 0 only on PASS. SETUP-FAILED (exit 2) when something could not be20
// built or found, so nothing was tested.22
import { spawnSync } from "node:child_process";23
import fs from "node:fs";24
import path from "node:path";25
import { fileURLToPath } from "node:url";27
const probeDir = path.dirname(fileURLToPath(import.meta.url));28
const repoRoot = path.resolve(probeDir, "../..");29
const hostBin = path.join(probeDir, "build/host/bin/sigil-crypto-wasm-probe");30
const wasmPath = path.join(probeDir, "build/web/sigil-crypto-wasm-probe.wasm");31
const staticBin = path.join(probeDir, "build/static/bin/sigil-crypto-wasm-probe");32
const cryptoC = path.join(repoRoot, "native/crypto.c");34
let failures = 0;35
function fail(msg) {36
console.error(`FAIL: ${msg}`);37
failures += 1;38
}39
function setupFailed(msg) {40
console.error(`SETUP-FAILED: ${msg}`);41
process.exit(2);42
}44
// Every non-target line and its expected value. A missing line fails.45
const EXPECTED = {46
"blake2b-512 abc":47
"ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923",48
"blake2b-512 binary.bin": null, // checked against coreutils b2sum below49
"ed25519 rfc8032 test 2 verifies": "#t",50
"ed25519 rfc8032 test 3 verifies": "#t",51
"ed25519 test 2 flipped message": "#f",52
"ed25519 test 3 flipped signature": "#f",53
"ed25519 test 3 flipped key": "#f",54
"ed25519 test 2 public key": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",55
"ed25519 test 2 signature":56
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",57
"ed25519 31-byte key": "ed25519-verify: public key must be 32 bytes",58
"minisign key a id": null, // checked against a.pub's comment line below59
"minisign hello ED": "sigil-crypto fixture: prehashed",60
"minisign hello Ed": "sigil-crypto fixture: legacy",61
"minisign binary ED": "sigil-crypto fixture: binary",62
"minisign registry root":63
"registry=pkg.usesigil.org path=/v1/meta/registry.json seq=1 ts=2026-07-30T08:04:54Z",64
"minisign registry tampered": "bad-signature",65
"minisign hello tampered": "bad-signature",66
"minisign key mismatch": "key-id-mismatch",67
"minisign key b": "#f",68
"minisign legacy refused": "legacy-refused",69
"minisign bad key line": "minisign: public key has the wrong base64 length",70
"timing-safe-equal?": "#t",71
};72
const TARGET_LINES = ["target: sha256", "target: base64url-encode"];73
const WASM_REFUSAL = "not available on wasm";75
function run(cmd, args, opts = {}) {76
const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });77
return { status: r.status, out: (r.stdout || "") + (r.stderr || ""), error: r.error };78
}80
function build() {81
// cwd is the probe directory: sigil resolves the project and its82
// dependencies from cwd, not from the location of any file.83
const host = run("nice", ["-n", "19", "sigil", "build", "--config", "host"], { cwd: probeDir });84
if (host.status !== 0) {85
process.stdout.write(host.out);86
setupFailed(`host build failed (exit ${host.status})`);87
}88
const stat = run("nice", ["-n", "19", "sigil", "build", "--config", "static"], { cwd: probeDir });89
if (stat.status !== 0) {90
process.stdout.write(stat.out);91
setupFailed(`static musl build failed (exit ${stat.status})`);92
}93
const web = run(94
"guix",95
["shell", "binaryen", "--", "sh", "-c", "command -v wasm-opt >/dev/null && nice -n 19 sigil build --config web"],96
{ cwd: probeDir },97
);98
if (web.status !== 0) {99
process.stdout.write(web.out);100
setupFailed(`web build failed (exit ${web.status})`);101
}102
}104
// The wasm build's objects, not its log (a cached build logs nothing): no105
// Mbed TLS object may exist, and Monocypher's must, which is the positive106
// control that this looks in the right place.107
function checkWasmObjects() {108
const objDir = path.join(probeDir, "build/web/native");109
const monocypherObj = path.join(objDir, "vendor/monocypher/src/monocypher.o");110
if (!fs.existsSync(monocypherObj)) setupFailed(`expected wasm object missing: ${monocypherObj}`);111
const mbedtlsDir = path.join(objDir, "vendor/mbedtls");112
const found = fs.existsSync(mbedtlsDir)113
? fs.readdirSync(mbedtlsDir, { recursive: true }).filter((f) => String(f).endsWith(".o"))114
: [];115
if (found.length > 0) fail(`the wasm build has ${found.length} Mbed TLS objects (e.g. ${found[0]})`);116
else console.log("wasm objects: Monocypher present, no Mbed TLS objects");117
}119
function describe(file) {120
if (!fs.existsSync(file)) setupFailed(`missing artifact ${file}`);121
const st = fs.statSync(file);122
console.log(`artifact ${file} ${st.size} bytes, mtime ${st.mtime.toISOString()}`);123
}125
// Minimal WASI preview1 shim, after sigil's test-native-facade-reexport.mjs.126
function makeWasi(module, output) {127
let instance = null;128
const decoder = new TextDecoder();129
const view = () => new DataView(instance.exports.memory.buffer);130
const u8 = () => new Uint8Array(instance.exports.memory.buffer);131
const known = {132
fd_write(fd, iovsPtr, iovsLen, nwrittenPtr) {133
let written = 0;134
let text = "";135
for (let i = 0; i < iovsLen; i += 1) {136
const ptr = view().getUint32(iovsPtr + i * 8, true);137
const len = view().getUint32(iovsPtr + i * 8 + 4, true);138
text += decoder.decode(u8().subarray(ptr, ptr + len));139
written += len;140
}141
if (fd === 1 || fd === 2) output.push(text);142
if (nwrittenPtr) view().setUint32(nwrittenPtr, written, true);143
return 0;144
},145
proc_exit(code) {146
throw new Error(`WASI proc_exit(${code})`);147
},148
args_sizes_get(argcPtr, argvBufSizePtr) {149
view().setUint32(argcPtr, 0, true);150
view().setUint32(argvBufSizePtr, 0, true);151
return 0;152
},153
environ_sizes_get(countPtr, bufSizePtr) {154
view().setUint32(countPtr, 0, true);155
view().setUint32(bufSizePtr, 0, true);156
return 0;157
},158
clock_time_get(_id, _precision, timePtr) {159
view().setBigUint64(timePtr, BigInt(Date.now()) * 1000000n, true);160
return 0;161
},162
// Ed25519 verification needs no entropy. The runtime may still ask163
// (hash seeds); this shim answers from Math.random, which is fine for164
// a test and would not be for anything that generates keys.165
random_get(ptr, len) {166
const bytes = u8();167
for (let i = 0; i < len; i += 1) bytes[ptr + i] = (Math.random() * 256) | 0;168
return 0;169
},170
fd_fdstat_get(_fd, statPtr) {171
u8().fill(0, statPtr, statPtr + 24);172
view().setUint8(statPtr, 2);173
return 0;174
},175
fd_fdstat_set_flags() { return 0; },176
fd_filestat_get(_fd, statPtr) {177
u8().fill(0, statPtr, statPtr + 64);178
view().setUint8(statPtr + 16, 2);179
return 0;180
},181
fd_close() { return 0; },182
fd_seek() { return 0; },183
fd_fdstat_set_rights() { return 0; },184
fd_prestat_get() { return 8; },185
fd_prestat_dir_name() { return 8; },186
path_open() { return 44; },187
};188
const wasi = {};189
for (const entry of WebAssembly.Module.imports(module)) {190
if (entry.module === "wasi_snapshot_preview1") {191
wasi[entry.name] = known[entry.name] || (() => 0);192
}193
}194
return {195
imports: { wasi_snapshot_preview1: wasi },196
setInstance(value) { instance = value; },197
};198
}200
function parseLines(text) {201
const map = new Map();202
const order = [];203
for (const line of text.split("\n")) {204
const i = line.indexOf(": ");205
if (i < 0) continue;206
const key = line.startsWith("target: ") ? line.slice(0, line.indexOf(": ", 8)) : line.slice(0, i);207
const value = line.slice(key.length + 2);208
map.set(key, value);209
order.push(key);210
}211
return { map, order };212
}214
async function runWasm() {215
const output = [];216
const module = await WebAssembly.compile(fs.readFileSync(wasmPath));217
const wasi = makeWasi(module, output);218
const extra = {};219
for (const entry of WebAssembly.Module.imports(module)) {220
if (entry.module === "wasi_snapshot_preview1") continue;221
extra[entry.module] ??= {};222
if (entry.kind === "function") extra[entry.module][entry.name] = () => 0;223
}224
const instance = await WebAssembly.instantiate(module, { ...wasi.imports, ...extra });225
wasi.setInstance(instance);226
const ex = instance.exports;227
for (const sym of ["sigil_wasm_init", "sigil_wasm_start", "sigil_wasm_eval_string_length", "malloc"]) {228
if (typeof ex[sym] !== "function") setupFailed(`wasm module does not export ${sym}`);229
}230
if (ex.sigil_wasm_init() !== 0) setupFailed("sigil_wasm_init failed");231
const startRc = ex.sigil_wasm_start();232
const compiled = output.join("");233
output.length = 0;235
const encoder = new TextEncoder();236
const evalStr = (expr) => {237
const bytes = encoder.encode(expr);238
const ptr = ex.malloc(bytes.length + 1);239
const mem = new Uint8Array(ex.memory.buffer);240
mem.set(bytes, ptr);241
mem[ptr + bytes.length] = 0;242
const rc = ex.sigil_wasm_eval_string_length(ptr);243
ex.free(ptr);244
if (rc < 0) return { rc, result: output.splice(0).join("") };245
const len = ex.sigil_wasm_last_result_length();246
const dest = ex.malloc(Math.max(len, 1));247
const n = ex.sigil_wasm_copy_result(dest, len);248
const text = new TextDecoder().decode(new Uint8Array(ex.memory.buffer, dest, n));249
ex.free(dest);250
return { rc, result: text.replace(/^"|"$/g, "") };251
};252
return { compiled, startRc, evalStr };253
}255
function checkExpected(label, lines) {256
for (const [key, want] of Object.entries(EXPECTED)) {257
if (!lines.map.has(key)) {258
fail(`${label}: line "${key}" missing`);259
} else if (want !== null && lines.map.get(key) !== want) {260
fail(`${label}: "${key}" = ${JSON.stringify(lines.map.get(key))}, want ${JSON.stringify(want)}`);261
}262
}263
}265
async function main() {266
if (process.env.SIGIL_PROBE_SKIP_BUILD !== "1") build();267
const lock = path.join(probeDir, "sigil.lock");268
if (fs.existsSync(lock)) {269
const entries = [...fs.readFileSync(lock, "utf8").matchAll(/name: "([^"]+)"[\s\S]*?sha: "([^"]*)"[\s\S]*?version: "([^"]+)"/g)];270
for (const [, name, sha, version] of entries) {271
if (name === "sigil-crypto") continue; // from ../..; its lock sha is stale by design272
console.log(`input ${name} ${version} ${sha.slice(0, 12)}`);273
}274
}275
{276
const head = run("git", ["-C", repoRoot, "rev-parse", "HEAD"]).out.trim();277
const dirty = run("git", ["-C", repoRoot, "status", "--porcelain", "--", ".", ":!test/wasm/sigil.lock"]).out.trim() !== "";278
console.log(`input sigil-crypto (from ../..) ${head.slice(0, 12)}${dirty ? " +uncommitted changes" : ""}`);279
}280
describe(hostBin);281
describe(wasmPath);282
describe(staticBin);283
checkWasmObjects();285
// Host run. cwd is the probe dir, for the same reason as the build.286
const host = run(hostBin, [], { cwd: probeDir });287
if (host.status !== 0 || !host.out.includes("REACHED-END")) {288
process.stdout.write(host.out);289
setupFailed(`host probe did not complete (exit ${host.status})`);290
}291
// Static musl build: must print exactly what the host prints, target292
// lines included (Mbed TLS is built there).293
const stat = run(staticBin, [], { cwd: probeDir });294
if (stat.status !== 0 || stat.out !== host.out) fail(`static musl output differs from host (exit ${stat.status})`);295
else console.log(`static musl: output identical to host (${host.out.trim().split("\n").length} lines)`);296
const wasm = await runWasm();297
process.stdout.write(`=== wasm (sigil_wasm_start -> ${wasm.startRc}) ===\n${wasm.compiled}`);298
if (!wasm.compiled.includes("REACHED-END")) fail("wasm probe did not reach the end");300
const h = parseLines(host.out);301
const w = parseLines(wasm.compiled);303
// Independent values for the two lines EXPECTED leaves open.304
const b2 = run("b2sum", [path.join(repoRoot, "test/fixtures/minisign/binary.bin")]);305
if (b2.status !== 0) setupFailed("b2sum did not run");306
EXPECTED["blake2b-512 binary.bin"] = b2.out.split(" ")[0];307
const pubComment = fs.readFileSync(path.join(repoRoot, "test/fixtures/minisign/a.pub"), "utf8").split("\n")[0];308
EXPECTED["minisign key a id"] = pubComment.slice(-16);310
checkExpected("host", h);311
checkExpected("wasm", w);313
// Line-for-line agreement outside the target lines.314
const hOrder = h.order.filter((k) => !k.startsWith("target: "));315
const wOrder = w.order.filter((k) => !k.startsWith("target: "));316
if (JSON.stringify(hOrder) !== JSON.stringify(wOrder)) fail("host and wasm print different line sequences");317
let agreed = 0;318
for (const key of hOrder) {319
if (h.map.get(key) === w.map.get(key)) agreed += 1;320
else fail(`differential: "${key}" host=${JSON.stringify(h.map.get(key))} wasm=${JSON.stringify(w.map.get(key))}`);321
}322
console.log(`differential: ${agreed}/${hOrder.length} lines agree`);324
for (const key of TARGET_LINES) {325
if (h.map.get(key) !== "no-error") fail(`host ${key}: ${JSON.stringify(h.map.get(key))}, want no-error`);326
if (!(w.map.get(key) || "").includes(WASM_REFUSAL)) fail(`wasm ${key}: ${JSON.stringify(w.map.get(key))}, want the refusal`);327
}329
// Drift gate: the stub list in crypto.c against the host registrations.330
const src = fs.readFileSync(cryptoC, "utf8");331
const stubBlock = src.match(/mbedtls_only_natives\[\] = \{([\s\S]*?)NULL/);332
if (!stubBlock) setupFailed("could not find mbedtls_only_natives in crypto.c");333
const stubs = [...stubBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();334
const registered = [...src.matchAll(/REGISTER_AND_EXPORT\("([^"]+)"/g)].map((m) => m[1]).sort();335
if (registered.length < 20) setupFailed(`found only ${registered.length} REGISTER_AND_EXPORT names`);336
if (JSON.stringify(stubs) !== JSON.stringify(registered)) {337
fail(`wasm stub list != host Mbed TLS registrations\n stubs only: ${stubs.filter((s) => !registered.includes(s))}\n host only: ${registered.filter((s) => !stubs.includes(s))}`);338
}339
let refused = 0;340
for (const name of registered) {341
const { rc, result } = wasm.evalStr(342
`(begin (import (sigil crypto)) (guard (e (#t (if (error-object? e) (error-object-message e) "non-error raise"))) (${name}) "no-error"))`,343
);344
if (rc >= 0 && result.includes(WASM_REFUSAL)) refused += 1;345
else fail(`wasm eval (${name}): rc=${rc} ${JSON.stringify(result)}`);346
}347
console.log(`drift gate: ${registered.length} Mbed TLS natives, ${refused} refuse on wasm`);348
// Positive control for the eval path itself: it must be able to succeed.349
const control = wasm.evalStr(`(begin (import (sigil crypto)) (number->string (bytevector-length (blake2b-512 "abc"))))`);350
if (control.rc < 0 || control.result !== "64") fail(`eval positive control: rc=${control.rc} ${JSON.stringify(control.result)}`);352
if (failures) {353
console.error(`FAIL: ${failures} check(s) failed`);354
process.exit(1);355
}356
console.log("PASS: sigil-crypto wasm build matches the host; Mbed TLS procedures refuse on wasm");357
}359
await main();