diff --git a/ct-runner/src/main.ts b/ct-runner/src/main.ts index d1e1caa..7a190df 100644 --- a/ct-runner/src/main.ts +++ b/ct-runner/src/main.ts @@ -104,7 +104,9 @@ function parseArgs(argv: string[]): Cli { positional.push(a); } } - if (positional.length !== 1) usageError("expected exactly one argument"); + if (positional.length !== 1) { + usageError("expected exactly one argument"); + } if (out === undefined) usageError("--out is required"); return { suitePath: positional[0], @@ -121,7 +123,9 @@ function parseArgs(argv: string[]): Cli { }; } -async function loadImportsModule(path: string): Promise> { +async function loadImportsModule( + path: string, +): Promise> { const mod = await import( path.startsWith(".") || path.startsWith("/") ? new URL(path, `file://${Deno.cwd()}/`).href @@ -142,7 +146,8 @@ async function loadImportsModule(path: string): Promise> * `polyengine-translator-shim.wasm` asset instead. */ async function loadTranslator(explicit?: string): Promise { const fromEnv = Deno.env.get("POLYENGINE_TRANSLATOR"); - const path = explicit ?? (fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined); + const path = explicit ?? + (fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined); if (path !== undefined) { let bytes: Uint8Array; try { @@ -150,7 +155,9 @@ async function loadTranslator(explicit?: string): Promise { } catch (e) { console.error( `error: cannot read translator wasm at ${path}` + - ` (${explicit !== undefined ? "--translator" : "POLYENGINE_TRANSLATOR"}): ${e}`, + ` (${ + explicit !== undefined ? "--translator" : "POLYENGINE_TRANSLATOR" + }): ${e}`, ); Deno.exit(1); } diff --git a/ct-runner/src/mod.ts b/ct-runner/src/mod.ts index 5c375ef..e5e634e 100644 --- a/ct-runner/src/mod.ts +++ b/ct-runner/src/mod.ts @@ -7,11 +7,7 @@ // provider, never composing wasm for L2. See src/run-suite.ts for the case // loop and src/context.ts for the host resource. -export { - type RunCounts, - runSuite, - type RunSuiteOptions, -} from "./run-suite.ts"; +export { type RunCounts, runSuite, type RunSuiteOptions } from "./run-suite.ts"; export { analyzeImports, diff --git a/ct-runner/src/run-suite.ts b/ct-runner/src/run-suite.ts index a0613cd..aa1be04 100644 --- a/ct-runner/src/run-suite.ts +++ b/ct-runner/src/run-suite.ts @@ -15,15 +15,10 @@ import { type ComponentArtifacts, instantiate, } from "@polyengine/runtime/embedder"; -import { Trap, ComponentException } from "@polyengine/protocol"; +import { ComponentException, Trap } from "@polyengine/protocol"; import { Context, testContextImportRecord } from "./context.ts"; -import { analyzeImports, requireImportsResolved } from "./import-analysis.ts"; -import { - applies, - firstExcluding, - loadTagsInventory, - tagsOf, -} from "./tags.ts"; +import { requireImportsResolved } from "./import-analysis.ts"; +import { applies, firstExcluding, loadTagsInventory, tagsOf } from "./tags.ts"; /** * The suite's `tests` interface id (wit/tests.wit `interface tests`, v0.1.0). @@ -310,12 +305,15 @@ export async function runSuite( } if (!applies(tags, missing)) { counts.na++; - opts.emit(JSON.stringify({ - case: name, - status: "not-applicable", - detail: firstExcluding(tags, missing), - "diagnostics-complete": true, - }), i); + opts.emit( + JSON.stringify({ + case: name, + status: "not-applicable", + detail: firstExcluding(tags, missing), + "diagnostics-complete": true, + }), + i, + ); opts.log?.(`${name} … not-applicable`); continue; } @@ -329,11 +327,14 @@ export async function runSuite( // `only ` detail — no other fields. if (!isSelected) { counts.deselected++; - opts.emit(JSON.stringify({ - case: name, - status: "deselected", - detail: `only ${opts.only}`, - }), i); + opts.emit( + JSON.stringify({ + case: name, + status: "deselected", + detail: `only ${opts.only}`, + }), + i, + ); opts.log?.(`${name} … deselected`); continue; } @@ -479,8 +480,13 @@ export async function runSuite( return counts; } -// deno-lint-ignore no-explicit-any -async function findByName(list: any[], name: string, hint?: number): Promise { +async function findByName( + // deno-lint-ignore no-explicit-any + list: any[], + name: string, + hint?: number, + // deno-lint-ignore no-explicit-any +): Promise { // Same-index fast path. Enumeration order is a hint, not a contract: real // suites enumerate deterministically, so the re-enumerated case is // virtually always at its census index — one name() round-trip instead of diff --git a/ct-runner/tests/cli_test.ts b/ct-runner/tests/cli_test.ts index 5f2f7f6..3ce3e8f 100644 --- a/ct-runner/tests/cli_test.ts +++ b/ct-runner/tests/cli_test.ts @@ -76,7 +76,9 @@ Deno.test({ name: "cli: POLYENGINE_TRANSLATOR env is honored", ignore: !ready, fn: async () => { - const { code, lines } = await runCli([], { POLYENGINE_TRANSLATOR: TRANSLATOR }); + const { code, lines } = await runCli([], { + POLYENGINE_TRANSLATOR: TRANSLATOR, + }); assertEq(code, 1); assertEq(lines!.length, 1 + 6 + 1); }, diff --git a/ct-runner/tests/e2e_test.ts b/ct-runner/tests/e2e_test.ts index 8bcc783..956aba0 100644 --- a/ct-runner/tests/e2e_test.ts +++ b/ct-runner/tests/e2e_test.ts @@ -6,7 +6,12 @@ import { assertEq } from "../../runtime/tests/support/asserts.ts"; import { runSuite } from "../src/mod.ts"; -import { artifactsOf, FULL_RUN_COUNTS, haveFixture, TEST_SUITE_WASM } from "./support.ts"; +import { + artifactsOf, + FULL_RUN_COUNTS, + haveFixture, + TEST_SUITE_WASM, +} from "./support.ts"; const ready = await haveFixture(TEST_SUITE_WASM); @@ -139,7 +144,8 @@ Deno.test({ }); Deno.test({ - name: "e2e: freshCases=false still runs to completion (single shared instance)", + name: + "e2e: freshCases=false still runs to completion (single shared instance)", ignore: !ready, fn: async () => { const artifacts = await artifactsOf(TEST_SUITE_WASM); diff --git a/ct-runner/tests/golden_test.ts b/ct-runner/tests/golden_test.ts index 0ba4da6..84542e5 100644 --- a/ct-runner/tests/golden_test.ts +++ b/ct-runner/tests/golden_test.ts @@ -9,12 +9,16 @@ import { assertEq } from "../../runtime/tests/support/asserts.ts"; import { runSuite } from "../src/mod.ts"; -import { artifactsOf, FULL_RUN_COUNTS, haveFixture, TEST_SUITE_WASM } from "./support.ts"; +import { + artifactsOf, + FULL_RUN_COUNTS, + haveFixture, + TEST_SUITE_WASM, +} from "./support.ts"; const ready = await haveFixture(TEST_SUITE_WASM); /** Strip nondeterministic fields for a byte-stable comparison. */ -// deno-lint-ignore no-explicit-any function normalize(line: string): string { const v = JSON.parse(line); if (v.suite?.["artifact-sha256"]) v.suite["artifact-sha256"] = ""; diff --git a/ct-runner/tests/import_analysis_test.ts b/ct-runner/tests/import_analysis_test.ts index c7fb3d4..56f2e34 100644 --- a/ct-runner/tests/import_analysis_test.ts +++ b/ct-runner/tests/import_analysis_test.ts @@ -9,7 +9,10 @@ import { MissingImportsError, requireImportsResolved, } from "../src/import-analysis.ts"; -import { TEST_CONTEXT_INTERFACE, testContextImportRecord } from "../src/context.ts"; +import { + TEST_CONTEXT_INTERFACE, + testContextImportRecord, +} from "../src/context.ts"; import { artifactsOf, haveFixture, TEST_SUITE_WASM } from "./support.ts"; const ready = await haveFixture(TEST_SUITE_WASM); @@ -89,7 +92,9 @@ Deno.test({ assertEq( analysis.missing.some((m) => m.includes("polymorph:websocket")), true, - `expected a polymorph:websocket leaf among: ${analysis.missing.join(", ")}`, + `expected a polymorph:websocket leaf among: ${ + analysis.missing.join(", ") + }`, ); let threw: unknown; diff --git a/ct-runner/tests/schema_validation_test.ts b/ct-runner/tests/schema_validation_test.ts index e99e215..0c287a7 100644 --- a/ct-runner/tests/schema_validation_test.ts +++ b/ct-runner/tests/schema_validation_test.ts @@ -29,7 +29,10 @@ function checkProvenance(p: unknown): void { if ( p !== null && typeof p === "object" && "limit-exceeded" in (p as object) ) { - assertEq(typeof (p as { "limit-exceeded": unknown })["limit-exceeded"], "string"); + assertEq( + typeof (p as { "limit-exceeded": unknown })["limit-exceeded"], + "string", + ); return; } throw new Error(`unrecognized provenance shape: ${JSON.stringify(p)}`); diff --git a/ct-runner/tests/shard_test.ts b/ct-runner/tests/shard_test.ts index d102974..06d07ed 100644 --- a/ct-runner/tests/shard_test.ts +++ b/ct-runner/tests/shard_test.ts @@ -5,7 +5,12 @@ import { assertEq } from "../../runtime/tests/support/asserts.ts"; import { runSuite } from "../src/mod.ts"; -import { artifactsOf, FULL_RUN_COUNTS, haveFixture, TEST_SUITE_WASM } from "./support.ts"; +import { + artifactsOf, + FULL_RUN_COUNTS, + haveFixture, + TEST_SUITE_WASM, +} from "./support.ts"; function assert(cond: boolean, msg = ""): void { if (!cond) throw new Error(msg || "assertion failed"); @@ -13,7 +18,6 @@ function assert(cond: boolean, msg = ""): void { /** Strip `duration-ms` (nondeterministic wall-clock) for stable comparison, * same normalization golden_test.ts applies. */ -// deno-lint-ignore no-explicit-any function normalize(line: string): string { const v = JSON.parse(line); if (typeof v["duration-ms"] === "number") delete v["duration-ms"]; diff --git a/ct-runner/tests/support.ts b/ct-runner/tests/support.ts index 23ff942..992e57c 100644 --- a/ct-runner/tests/support.ts +++ b/ct-runner/tests/support.ts @@ -38,12 +38,19 @@ export function artifactsOfBytes( return { plan, componentBytes, adapters }; } -export const TEST_SUITE_WASM = "examples/guests/build/test-suite.component.wasm"; +export const TEST_SUITE_WASM = + "examples/guests/build/test-suite.component.wasm"; /** `runSuite`'s tally for an unfiltered, untagged run of TEST_SUITE_WASM * (4 pass, 1 fail, 1 skip; nothing gated or deselected). Tests whose * subject is not the counts assert this whole; tests ABOUT `only`/tags * spell out their own. */ export const FULL_RUN_COUNTS: RunCounts = { - passed: 4, failed: 1, skipped: 1, na: 0, deselected: 0, selected: 6, total: 6, + passed: 4, + failed: 1, + skipped: 1, + na: 0, + deselected: 0, + selected: 6, + total: 6, }; diff --git a/ct-runner/tests/tags_test.ts b/ct-runner/tests/tags_test.ts index e2e2310..5086951 100644 --- a/ct-runner/tests/tags_test.ts +++ b/ct-runner/tests/tags_test.ts @@ -13,7 +13,6 @@ import { applies, collectTagsSections, firstExcluding, - loadTagsInventory, parseTagsRecords, TAGS_SECTION, tagsOf, @@ -113,13 +112,27 @@ Deno.test("tags: scanner finds nested core-module sections and repairs newlines" // module) plus the concatenation/newline-repair path (inventory.rs). const coreCustom = customSection(TAGS_SECTION, enc.encode("m/core hsm")); const core = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // core preamble + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, // core preamble ...coreCustom, ]); const moduleSection = new Uint8Array([0x01, ...leb(core.length), ...core]); const componentCustom = customSection(TAGS_SECTION, enc.encode("m/comp\n")); const component = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00, // component preamble + 0x00, + 0x61, + 0x73, + 0x6d, + 0x0d, + 0x00, + 0x01, + 0x00, // component preamble ...moduleSection, ...componentCustom, ]); @@ -145,7 +158,8 @@ const RECORDS = "suite/basic/pass\n" + "suite/nested/deep/leaf\n"; Deno.test({ - name: "tags e2e: missing feature schedules the requiring case out (N/A row exact)", + name: + "tags e2e: missing feature schedules the requiring case out (N/A row exact)", ignore: !ready, fn: async () => { const bytes = withTags((await readArtifact(TEST_SUITE_WASM))!, RECORDS); @@ -186,7 +200,8 @@ Deno.test({ }); Deno.test({ - name: "tags e2e: gating is on whenever an inventory exists (decline case N/As)", + name: + "tags e2e: gating is on whenever an inventory exists (decline case N/As)", ignore: !ready, fn: async () => { const bytes = withTags((await readArtifact(TEST_SUITE_WASM))!, RECORDS); @@ -283,7 +298,8 @@ Deno.test({ }); Deno.test({ - name: "tags e2e: --missing without an inventory refuses (no silent feature-blind run)", + name: + "tags e2e: --missing without an inventory refuses (no silent feature-blind run)", ignore: !ready, fn: async () => { const bytes = (await readArtifact(TEST_SUITE_WASM))!; // no section diff --git a/justfile b/justfile index 51e7cfa..3e7c73b 100644 --- a/justfile +++ b/justfile @@ -18,14 +18,18 @@ check: fmt-check lint build test-rust cd wasi && deno task check cd ct-runner && deno task check -# Runtime formatting; generated files are excluded by runtime/deno.json. +# The five published packages are formatter- and lint-clean (`deno fmt`, +# `deno lint`, stock rules). Generated artifacts are excluded per package in +# its deno.json (runtime's bindgen snapshots and fixture output). harness, +# examples, and tools are not shipped and are not gated. +shipped := "protocol runtime translator wasi ct-runner" + +# Fix with `cd && deno fmt`. fmt-check: - cd runtime && deno fmt --check + for p in {{shipped}}; do (cd $p && deno fmt --check) || exit 1; done -# The runtime package is lint-clean (`deno lint`, stock rules; generated -# artifacts excluded in runtime/deno.json alongside fmt). lint: - cd runtime && deno lint + for p in {{shipped}}; do (cd $p && deno lint) || exit 1; done # ----- builders --------------------------------------------------------------- diff --git a/protocol/deno.json b/protocol/deno.json index 099b2c4..4d80331 100644 --- a/protocol/deno.json +++ b/protocol/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/protocol", - "version": "0.3.1", + "version": "0.3.2", "exports": { ".": "./src/mod.ts" }, diff --git a/protocol/src/errors.ts b/protocol/src/errors.ts index d0fc0b6..06166a9 100644 --- a/protocol/src/errors.ts +++ b/protocol/src/errors.ts @@ -28,6 +28,7 @@ // which is a worse footgun than brand-based recognition would create. import { + COMPONENT_EXCEPTION, defineBrand, DROPPED, hasBrand, @@ -35,7 +36,6 @@ import { PEER_TRAPPED, STREAM_PRODUCER, TRAP, - COMPONENT_EXCEPTION, } from "./brands.ts"; /** A WIT `result` err value, branded. `payload` is shaped per the value table. */ diff --git a/protocol/src/handles.ts b/protocol/src/handles.ts index dec18d6..9e38105 100644 --- a/protocol/src/handles.ts +++ b/protocol/src/handles.ts @@ -15,7 +15,13 @@ // lib.dom/lib.esnext ambient types (`ReadableStream`, `Uint8Array`, // `PromiseLike`, `AsyncIterable`, `Iterable`). -import { ERROR_CONTEXT, FUTURE, hasBrand, STREAM, STREAM_WRITER } from "./brands.ts"; +import { + ERROR_CONTEXT, + FUTURE, + hasBrand, + STREAM, + STREAM_WRITER, +} from "./brands.ts"; /** `Chunk` is a `Uint8Array`; every other element type chunks as `T[]`. */ export type Chunk = T extends number ? Uint8Array | T[] : T[]; diff --git a/protocol/src/mark_decorator.ts b/protocol/src/mark_decorator.ts index 3e7dcd5..d57e3ea 100644 --- a/protocol/src/mark_decorator.ts +++ b/protocol/src/mark_decorator.ts @@ -5,7 +5,7 @@ // functions stay in lockstep without copy-paste. Not exported from mod.ts — // intra-package only. -import { defineBrand, hasBrand } from "./brands.ts"; +import { defineBrand } from "./brands.ts"; /** * Build a mark function `(fn, context?, legacyDescriptor?) => fn` that diff --git a/protocol/src/mod.ts b/protocol/src/mod.ts index 11ab661..3fab21a 100644 --- a/protocol/src/mod.ts +++ b/protocol/src/mod.ts @@ -15,9 +15,10 @@ export { ABORTABLE, + COMPONENT_EXCEPTION, + DEFER_CANCEL, defineBrand, defineRealmLocal, - DEFER_CANCEL, DROPPED, ERROR_CONTEXT, FUTURE, @@ -35,22 +36,21 @@ export { SUSPENDING, TRAP, WASI_EXIT, - COMPONENT_EXCEPTION, } from "./brands.ts"; export { + ComponentException, DroppedError, InvalidHandleError, + isComponentException, isDroppedError, isInvalidHandleError, isPeerTrappedError, isStreamProducerError, isTrap, - isComponentException, PeerTrappedError, StreamProducerError, Trap, - ComponentException, } from "./errors.ts"; // Stream/future handles (contracts/embedder-api.md §"Streams and futures"; @@ -93,6 +93,6 @@ export { anySuspendingImport, isSuspending, suspending } from "./suspending.ts"; export { copyCensus, registerRuntimeCopy, - type RuntimeCopy, runtimeCopies, + type RuntimeCopy, } from "./registry.ts"; diff --git a/protocol/tests/brands_test.ts b/protocol/tests/brands_test.ts index fa39077..45b5454 100644 --- a/protocol/tests/brands_test.ts +++ b/protocol/tests/brands_test.ts @@ -8,7 +8,7 @@ import { assertEquals } from "./assert.ts"; import * as brands from "../src/brands.ts"; -import { PROTOCOL_GENERATION, ComponentException } from "../src/mod.ts"; +import { ComponentException, PROTOCOL_GENERATION } from "../src/mod.ts"; const EXPECTED: Record = { "polyengine.componentException/1": brands.COMPONENT_EXCEPTION, @@ -73,7 +73,10 @@ Deno.test("brands are non-enumerable and non-writable on prototypes", () => { assertEquals(d?.enumerable, false); assertEquals(d?.writable, false); // Not inherited by plain objects, and invisible to value walks. - assertEquals(Object.keys(new ComponentException(1)).includes("payload"), true); + assertEquals( + Object.keys(new ComponentException(1)).includes("payload"), + true, + ); assertEquals( Object.getOwnPropertySymbols(new ComponentException(1)).length, 0, diff --git a/protocol/tests/defer_cancel_test.ts b/protocol/tests/defer_cancel_test.ts index 564c23d..fbbddb8 100644 --- a/protocol/tests/defer_cancel_test.ts +++ b/protocol/tests/defer_cancel_test.ts @@ -8,7 +8,12 @@ // where the subtask machinery lives (runtime/tests/host_import_cancel_test.ts). import { assert, assertEquals, assertFalse, assertThrows } from "./assert.ts"; -import { deferCancel, isDeferCancel, isSuspending, suspending } from "../src/mod.ts"; +import { + deferCancel, + isDeferCancel, + isSuspending, + suspending, +} from "../src/mod.ts"; Deno.test("deferCancel() marks in place and the brand reads back", () => { const fn = (a: number) => a; @@ -48,7 +53,10 @@ Deno.test("the mark is non-enumerable (invisible to imports-record walks)", () = const fn = deferCancel(() => 1); assertEquals(Object.getOwnPropertySymbols(fn).length, 1); assertEquals( - Object.propertyIsEnumerable.call(fn, Symbol.for("polyengine.deferCancel/1")), + Object.propertyIsEnumerable.call( + fn, + Symbol.for("polyengine.deferCancel/1"), + ), false, ); // Re-marking is a no-op, not a TypeError on a non-configurable property. @@ -90,7 +98,8 @@ Deno.test("the legacy experimentalDecorators convention is refused with guidance // Under that convention the decorator receives the PROTOTYPE, not the // method: marking it would brand the wrong object AND corrupt the descriptor. const e = assertThrows( - () => deferCancel((() => 1) as CallableFunction, "flush", { value: () => 1 }), + () => + deferCancel((() => 1) as CallableFunction, "flush", { value: () => 1 }), TypeError, ); assert(e.message.includes("experimentalDecorators")); diff --git a/protocol/tests/errors_test.ts b/protocol/tests/errors_test.ts index abb36d7..f514c2d 100644 --- a/protocol/tests/errors_test.ts +++ b/protocol/tests/errors_test.ts @@ -9,18 +9,18 @@ import { assert, assertEquals, assertFalse } from "./assert.ts"; import { + ComponentException, DroppedError, InvalidHandleError, + isComponentException, isDroppedError, isInvalidHandleError, isPeerTrappedError, isStreamProducerError, isTrap, - isComponentException, PeerTrappedError, StreamProducerError, Trap, - ComponentException, } from "../src/mod.ts"; Deno.test("canonical classes are recognized by their own predicate", () => { @@ -69,7 +69,11 @@ Deno.test("unbranded look-alikes are refused", () => { assertFalse(isComponentException("polyengine.componentException/1")); assertFalse(isComponentException(42)); // Present but not exactly `true`: refused (no truthiness coercion). - assertFalse(isComponentException({ [Symbol.for("polyengine.componentException/1")]: 1 })); + assertFalse( + isComponentException({ + [Symbol.for("polyengine.componentException/1")]: 1, + }), + ); }); Deno.test("predicates are NOT instanceof — a foreign prototype passes", () => { @@ -88,7 +92,10 @@ Deno.test("predicates are NOT instanceof — a foreign prototype passes", () => { value: true }, ); const e = new ForeignComponentException({ kind: "x" }); - assertFalse(e instanceof ComponentException, "premise: class identity differs"); + assertFalse( + e instanceof ComponentException, + "premise: class identity differs", + ); assert(isComponentException(e), "brand identity holds"); }); diff --git a/protocol/tests/handles_test.ts b/protocol/tests/handles_test.ts index 4adc972..fe9145a 100644 --- a/protocol/tests/handles_test.ts +++ b/protocol/tests/handles_test.ts @@ -57,5 +57,7 @@ Deno.test("isErrorContext requires the brand AND a string message", () => { Deno.test("the three stateful brands don't cross-talk", () => { assertFalse(isStream({ [FUTURE]: true })); assertFalse(isFuture({ [STREAM]: true })); - assertFalse(isStreamWriter({ [Symbol.for("polyengine.errorContext/1")]: true })); + assertFalse( + isStreamWriter({ [Symbol.for("polyengine.errorContext/1")]: true }), + ); }); diff --git a/protocol/tests/suspending_test.ts b/protocol/tests/suspending_test.ts index 56a2e25..024d16c 100644 --- a/protocol/tests/suspending_test.ts +++ b/protocol/tests/suspending_test.ts @@ -31,16 +31,23 @@ Deno.test("the mark is the process-global brand, not a module-local symbol", () ); // Hand-rolled: a zero-import host module can declare suspendability with // nothing but the registry symbol (brands are markers, not gatekeepers). - const hand = Object.defineProperty(() => 1, Symbol.for("polyengine.suspending/1"), { - value: true, - }); + const hand = Object.defineProperty( + () => 1, + Symbol.for("polyengine.suspending/1"), + { + value: true, + }, + ); assert(isSuspending(hand)); }); Deno.test("the mark is non-enumerable (invisible to imports-record walks)", () => { const fn = suspending(() => 1); assertEquals(Object.getOwnPropertySymbols(fn).length, 1); - assertEquals(Object.propertyIsEnumerable.call(fn, Symbol.for("polyengine.suspending/1")), false); + assertEquals( + Object.propertyIsEnumerable.call(fn, Symbol.for("polyengine.suspending/1")), + false, + ); // Re-marking is a no-op, not a TypeError on a non-configurable property. suspending(fn); assert(isSuspending(fn)); diff --git a/translator/tests/mod_test.ts b/translator/tests/mod_test.ts index 0057b51..a271f36 100644 --- a/translator/tests/mod_test.ts +++ b/translator/tests/mod_test.ts @@ -18,7 +18,10 @@ async function maybeRead(url: URL): Promise { } const trivial = await maybeRead( - new URL("../../crates/translator-shim/testdata/trivial.wasm", import.meta.url), + new URL( + "../../crates/translator-shim/testdata/trivial.wasm", + import.meta.url, + ), ); const asset = await maybeRead( new URL("../translator_shim.wasm", import.meta.url), @@ -26,7 +29,8 @@ const asset = await maybeRead( const ready = trivial !== null && asset !== null; Deno.test({ - name: "defaultTranslator: loads, translates, and matches a bytes-built Translator", + name: + "defaultTranslator: loads, translates, and matches a bytes-built Translator", ignore: !ready, fn: async () => { const t = await defaultTranslator(); diff --git a/wasi/src/cli.ts b/wasi/src/cli.ts index 601918a..4cad7be 100644 --- a/wasi/src/cli.ts +++ b/wasi/src/cli.ts @@ -132,7 +132,9 @@ export function cli(options: CliOptions = {}): CliResult { ) => async (data: CliByteSource): Promise => { for await (const chunk of data as AsyncIterable) { - const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); + const bytes = chunk instanceof Uint8Array + ? chunk + : Uint8Array.from(chunk); chunks.push(bytes); mirror?.(new TextDecoder().decode(bytes)); } @@ -141,7 +143,8 @@ export function cli(options: CliOptions = {}): CliResult { const imports: Record = { "wasi:cli/environment@0.2": { - getEnvironment: (): [string, string][] => Object.entries(options.env ?? {}), + getEnvironment: (): [string, string][] => + Object.entries(options.env ?? {}), getArguments: (): string[] => options.args ?? [], initialCwd: (): string | undefined => options.cwd, }, @@ -174,7 +177,8 @@ export function cli(options: CliOptions = {}): CliResult { // ---- the @0.3 track (WASI 0.3.1 shapes; module header) ------------------- "wasi:cli/types@0.3": {}, "wasi:cli/environment@0.3": { - getEnvironment: (): [string, string][] => Object.entries(options.env ?? {}), + getEnvironment: (): [string, string][] => + Object.entries(options.env ?? {}), getArguments: (): string[] => options.args ?? [], getInitialCwd: (): string | undefined => options.cwd, }, diff --git a/wasi/src/cli_stdio.ts b/wasi/src/cli_stdio.ts index a3792b2..d76313c 100644 --- a/wasi/src/cli_stdio.ts +++ b/wasi/src/cli_stdio.ts @@ -111,7 +111,9 @@ interface NodeProcess { function hostProcess(): NodeProcess | undefined { const proc = (globalThis as { process?: unknown }).process; - return typeof proc === "object" && proc !== null ? (proc as NodeProcess) : undefined; + return typeof proc === "object" && proc !== null + ? (proc as NodeProcess) + : undefined; } function processSink(stream: NodeProcessStream): ByteSink { @@ -135,7 +137,10 @@ export function cliStdio(options: CliStdioOptions = {}): CliStdio { (proc?.stdout === undefined ? undefined : processSink(proc.stdout)); const stderrSink = options.stderr ?? (proc?.stderr === undefined ? undefined : processSink(proc.stderr)); - if (stdinSource === undefined || stdoutSink === undefined || stderrSink === undefined) { + if ( + stdinSource === undefined || stdoutSink === undefined || + stderrSink === undefined + ) { throw new TypeError( "cliStdio: no host process stdio and no injected replacement — " + "on hosts without `process` (browsers), inject sources/sinks or " + @@ -143,14 +148,17 @@ export function cliStdio(options: CliStdioOptions = {}): CliStdio { ); } const tty = { - stdin: options.isTty?.stdin ?? (proc?.stdin as { isTTY?: boolean } | undefined)?.isTTY ?? false, + stdin: options.isTty?.stdin ?? + (proc?.stdin as { isTTY?: boolean } | undefined)?.isTTY ?? false, stdout: options.isTty?.stdout ?? proc?.stdout?.isTTY ?? false, stderr: options.isTty?.stderr ?? proc?.stderr?.isTTY ?? false, }; const env = (): [string, string][] => { if (options.env !== undefined) return Object.entries(options.env); const e = proc?.env ?? {}; - return Object.entries(e).filter((kv): kv is [string, string] => kv[1] !== undefined); + return Object.entries(e).filter((kv): kv is [string, string] => + kv[1] !== undefined + ); }; const args = (): string[] => options.args ?? proc?.argv?.slice(2) ?? []; const cwd = (): string | undefined => options.cwd ?? proc?.cwd?.(); @@ -170,20 +178,28 @@ export function cliStdio(options: CliStdioOptions = {}): CliStdio { // 0.3 write-via-stream: drain the guest's stream to the sink; the // promise is the future source (embedder-api.md §"Streams and futures"). - const writeViaStream = (sink: ByteSink) => async (data: CliByteSource): Promise => { - try { - for await (const chunk of data as AsyncIterable) { - await sink(chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk)); + const writeViaStream = + (sink: ByteSink) => async (data: CliByteSource): Promise => { + try { + for await ( + const chunk of data as AsyncIterable + ) { + await sink( + chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk), + ); + } + return OK; + } catch (e) { + if (isStream(data)) data.drop(); // the guest's writer must not hang + return { kind: "err", value: ioErrorCode(e) }; } - return OK; - } catch (e) { - if (isStream(data)) data.drop(); // the guest's writer must not hang - return { kind: "err", value: ioErrorCode(e) }; - } - }; + }; // 0.3 read-via-stream: the tcp-receive tuple over the shared source. - const readViaStream = (): [AsyncIterable, Promise] => { + const readViaStream = (): [ + AsyncIterable, + Promise, + ] => { let settle!: (r: CliIoResult) => void; const done = new Promise((r) => (settle = r)); const source = (async function* (): AsyncGenerator { @@ -215,7 +231,8 @@ export function cliStdio(options: CliStdioOptions = {}): CliStdio { }, }, "wasi:cli/stdin@0.2": { - getStdin: (): FedInputStream => (p2Stdin ??= new FedInputStream(stdinSource)), + getStdin: + (): FedInputStream => (p2Stdin ??= new FedInputStream(stdinSource)), }, "wasi:cli/stdout@0.2": { getStdout: (): SinkOutputStream => p2Stdout }, "wasi:cli/stderr@0.2": { getStderr: (): SinkOutputStream => p2Stderr }, diff --git a/wasi/src/clocks.ts b/wasi/src/clocks.ts index 2e34688..99bf20d 100644 --- a/wasi/src/clocks.ts +++ b/wasi/src/clocks.ts @@ -19,8 +19,11 @@ function sleep(ms: number): Promise { } /** `wasi:clocks@0.2` + `wasi:clocks@0.3` provider fragment (two track keys). */ -export function clocks(options: ClocksOptions = {}): { imports: Record } { - const nowFn = options.now ?? ((): bigint => BigInt(Math.round(performance.now() * 1e6))); +export function clocks( + options: ClocksOptions = {}, +): { imports: Record } { + const nowFn = options.now ?? + ((): bigint => BigInt(Math.round(performance.now() * 1e6))); // A coarse but honest resolution: this clock is JS-timer-backed, not a // real hardware tick; 1 microsecond avoids claiming false precision. const RESOLUTION_NS = 1_000n; diff --git a/wasi/src/filesystem_node.ts b/wasi/src/filesystem_node.ts index 740ab42..77bdd21 100644 --- a/wasi/src/filesystem_node.ts +++ b/wasi/src/filesystem_node.ts @@ -64,7 +64,6 @@ import { makeFilesystem, type MaybeAsync, type Opened, - type OpenOptions, type TimeSpec, } from "./internal/fs_provider.ts"; @@ -111,8 +110,20 @@ interface NodeFsModule { }; openSync(path: string, flags: number): number; closeSync(fd: number): void; - readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number): number; - writeSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number): number; + readSync( + fd: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number, + ): number; + writeSync( + fd: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number, + ): number; fstatSync(fd: number, opts: { bigint: true }): NodeBigIntStats; statSync(path: string, opts: { bigint: true }): NodeBigIntStats; lstatSync(path: string, opts: { bigint: true }): NodeBigIntStats; @@ -216,7 +227,10 @@ function direntType(d: { return "unknown"; } -function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend { +function makeNodeBackend( + fs: NodeFsModule, + path: NodePathModule, +): FsBackend { const join = (base: NodeHandle, segments: string[]): string => segments.length === 0 ? base.path : `${base.path}/${segments.join("/")}`; @@ -229,7 +243,9 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend - Object.assign(new Error(`path escapes the preopen: ${full}`), { code: "EPERM" }); + Object.assign(new Error(`path escapes the preopen: ${full}`), { + code: "EPERM", + }); /** `real` must be the root itself or strictly beneath it. */ const requireInside = (real: string, root: string, full: string): void => { @@ -264,7 +280,12 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend { + const walkReal = ( + dir: string, + rel: string, + root: string, + full: string, + ): string => { let d = dir; for (const seg of rel.split(path.sep)) { if (seg === "" || seg === ".") continue; @@ -333,7 +354,9 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend { + const guard = ( + base: NodeHandle, + segments: string[], + follow: boolean, + ): string => { const full = join(base, segments); if (segments.length === 0) { // The base itself (no parent to check — its parent is typically the @@ -359,7 +386,9 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend { if (h.fd === undefined) { // Directory handles carry no fd; byte ops on one are EISDIR. - throw Object.assign(new Error("descriptor is a directory"), { code: "EISDIR" }); + throw Object.assign(new Error("descriptor is a directory"), { + code: "EISDIR", + }); } return h.fd; }; @@ -374,7 +403,9 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend - h.fd === undefined ? fs.statSync(h.path, { bigint: true }) : fs.fstatSync(h.fd, { bigint: true }); + h.fd === undefined + ? fs.statSync(h.path, { bigint: true }) + : fs.fstatSync(h.fd, { bigint: true }); /** node utimes take seconds (fractional); "no-change" re-applies the * current value (POSIX UTIME_OMIT has no node spelling). */ @@ -437,13 +468,18 @@ function makeNodeBackend(fs: NodeFsModule, path: NodePathModule): FsBackend { const real = fs.realpathSync(hostPath); if (!fs.statSync(real, { bigint: true }).isDirectory()) { - throw new TypeError(`filesystemNode: preopen ${hostPath} is not a directory`); + throw new TypeError( + `filesystemNode: preopen ${hostPath} is not a directory`, + ); } return [{ path: real, root: real, type: "directory" }, guestName]; }, diff --git a/wasi/src/filesystem_web.ts b/wasi/src/filesystem_web.ts index be792a3..861afda 100644 --- a/wasi/src/filesystem_web.ts +++ b/wasi/src/filesystem_web.ts @@ -47,7 +47,6 @@ import { type FsStat, makeFilesystem, type Opened, - type OpenOptions, } from "./internal/fs_provider.ts"; // --- the OPFS surface we consume (structural: works with fakes) ------------------- @@ -60,7 +59,9 @@ export interface OpfsFileLike { } export interface OpfsWritable { - write(params: { type: "write"; position: number; data: Uint8Array }): Promise; + write( + params: { type: "write"; position: number; data: Uint8Array }, + ): Promise; truncate(size: number): Promise; close(): Promise; } @@ -78,8 +79,14 @@ export interface OpfsFileHandle { export interface OpfsDirectoryHandle { readonly kind: "directory"; readonly name: string; - getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise; - getFileHandle(name: string, opts?: { create?: boolean }): Promise; + getDirectoryHandle( + name: string, + opts?: { create?: boolean }, + ): Promise; + getFileHandle( + name: string, + opts?: { create?: boolean }, + ): Promise; removeEntry(name: string, opts?: { recursive?: boolean }): Promise; entries(): AsyncIterable<[string, OpfsDirectoryHandle | OpfsFileHandle]>; isSameEntry(other: OpfsFileHandle | OpfsDirectoryHandle): Promise; @@ -142,7 +149,8 @@ function makeWebBackend(): FsBackend { return dir; }; - const parentOf = (base: WebHandle, segments: string[]) => walk(base, segments.slice(0, -1)); + const parentOf = (base: WebHandle, segments: string[]) => + walk(base, segments.slice(0, -1)); /** Resolve segments to a handle (file or directory), never creating. */ const resolve = async ( @@ -172,7 +180,9 @@ function makeWebBackend(): FsBackend { }; }; - const statOfHandle = (h: OpfsDirectoryHandle | OpfsFileHandle): Promise => + const statOfHandle = ( + h: OpfsDirectoryHandle | OpfsFileHandle, + ): Promise => h.kind === "file" ? statOfFile(h) : Promise.resolve({ type: "directory" as const, linkCount: 1n, @@ -188,7 +198,11 @@ function makeWebBackend(): FsBackend { return { a: h, b: BigInt(path.length) }; }; - const writeAt = async (file: OpfsFileHandle, data: Uint8Array, position: number): Promise => { + const writeAt = async ( + file: OpfsFileHandle, + data: Uint8Array, + position: number, + ): Promise => { const w = await file.createWritable({ keepExistingData: true }); try { await w.write({ type: "write", position, data }); @@ -204,21 +218,29 @@ function makeWebBackend(): FsBackend { const tagged = (e as { fsCode?: FsErrorCode })?.fsCode; if (tagged !== undefined) return tagged; const name = (e as { name?: unknown })?.name; - return (typeof name === "string" ? DOM_ERROR_MAP[name] : undefined) ?? "io"; + return (typeof name === "string" ? DOM_ERROR_MAP[name] : undefined) ?? + "io"; }, async openAt(base, segments, opts): Promise> { const path = childPath(base, segments); if (segments.length === 0) { // Opening "." — the base itself. - if (opts.exclusive && opts.create) throw domError("exist", `${path}: exists`); - return { handle: { handle: base.handle, path }, type: base.handle.kind === "file" ? "regular-file" : "directory" }; + if (opts.exclusive && opts.create) { + throw domError("exist", `${path}: exists`); + } + return { + handle: { handle: base.handle, path }, + type: base.handle.kind === "file" ? "regular-file" : "directory", + }; } const parent = await parentOf(base, segments); const name = segments[segments.length - 1]; if (opts.directory) { - const dir = await parent.getDirectoryHandle(name, { create: opts.create }); + const dir = await parent.getDirectoryHandle(name, { + create: opts.create, + }); return { handle: { handle: dir, path }, type: "directory" }; } @@ -228,12 +250,15 @@ function makeWebBackend(): FsBackend { existing = await parent.getFileHandle(name); } catch (e) { const en = (e as { name?: string })?.name; - if (en === "TypeMismatchError") existing = await parent.getDirectoryHandle(name); - else if (en !== "NotFoundError") throw e; + if (en === "TypeMismatchError") { + existing = await parent.getDirectoryHandle(name); + } else if (en !== "NotFoundError") throw e; } if (existing?.kind === "directory") { - if (opts.create && opts.exclusive) throw domError("exist", `${path}: exists`); + if (opts.create && opts.exclusive) { + throw domError("exist", `${path}: exists`); + } return { handle: { handle: existing, path }, type: "directory" }; } if (existing !== undefined && opts.create && opts.exclusive) { @@ -305,7 +330,10 @@ function makeWebBackend(): FsBackend { async readDirectory(h): Promise<{ name: string; type: DescriptorType }[]> { const out: { name: string; type: DescriptorType }[] = []; for await (const [name, handle] of requireDir(h).entries()) { - out.push({ name, type: handle.kind === "directory" ? "directory" : "regular-file" }); + out.push({ + name, + type: handle.kind === "directory" ? "directory" : "regular-file", + }); } return out; }, @@ -322,7 +350,9 @@ function makeWebBackend(): FsBackend { if ((e as { name?: string })?.name !== "NotFoundError") throw e; exists = false; } - if (exists) throw domError("exist", `${childPath(base, segments)}: exists`); + if (exists) { + throw domError("exist", `${childPath(base, segments)}: exists`); + } await parent.getDirectoryHandle(name, { create: true }); }, @@ -340,7 +370,10 @@ function makeWebBackend(): FsBackend { await parent.getFileHandle(name); } catch (e) { if ((e as { name?: string })?.name === "TypeMismatchError") { - throw domError("is-directory", `${childPath(base, segments)}: is a directory`); + throw domError( + "is-directory", + `${childPath(base, segments)}: is a directory`, + ); } throw e; } @@ -356,10 +389,15 @@ function makeWebBackend(): FsBackend { return; } if (target.kind === "directory") { - throw domError("unsupported", "OPFS: directory rename requires FileSystemHandle.move"); + throw domError( + "unsupported", + "OPFS: directory rename requires FileSystemHandle.move", + ); } // Fallback: copy + delete (non-atomic, module header). - const bytes = new Uint8Array(await (await target.getFile()).arrayBuffer()); + const bytes = new Uint8Array( + await (await target.getFile()).arrayBuffer(), + ); const dest = await newParent.getFileHandle(newName, { create: true }); const w = await dest.createWritable({ keepExistingData: false }); try { @@ -404,11 +442,15 @@ export interface FilesystemWebOptions extends FilesystemAccessOptions { * `wasi:filesystem` over the Origin Private File System (module header). * Serves both the `@0.2` (parking, JSPI) and `@0.3` tracks. */ -export function filesystemWeb(options: FilesystemWebOptions): FilesystemFragment { +export function filesystemWeb( + options: FilesystemWebOptions, +): FilesystemFragment { const preopens: [WebHandle, string][] = Object.entries(options.preopens).map( ([guestName, handle]) => { if (handle.kind !== "directory") { - throw new TypeError(`filesystemWeb: preopen ${guestName} is not a directory handle`); + throw new TypeError( + `filesystemWeb: preopen ${guestName} is not a directory handle`, + ); } return [{ handle, path: guestName }, guestName]; }, diff --git a/wasi/src/http.ts b/wasi/src/http.ts index a9a9592..d2ab736 100644 --- a/wasi/src/http.ts +++ b/wasi/src/http.ts @@ -68,7 +68,11 @@ // Fetch failures are TypeErrors with prose; a small sniff table maps the // recognizable ones and everything else is `internal-error(message)`. -import { ComponentException, isComponentException, type Stream } from "@polyengine/protocol"; +import { + ComponentException, + isComponentException, + type Stream, +} from "@polyengine/protocol"; /** * The compatibility track the fragment registers on by default. @@ -132,7 +136,10 @@ export type HttpResult = { kind: "ok" } | { kind: "err"; value: ErrorCode }; const OK: HttpResult = { kind: "ok" }; -function httpError(payload: ErrorCode, detail: string): ComponentException { +function httpError( + payload: ErrorCode, + detail: string, +): ComponentException { return new ComponentException(payload, `wasi:http: ${detail}`); } @@ -140,7 +147,10 @@ function headerError( kind: "invalid-syntax" | "forbidden" | "immutable" | "size-exceeded", detail: string, ): ComponentException { - return new ComponentException({ kind }, `wasi:http/types: ${detail}`); + return new ComponentException( + { kind }, + `wasi:http/types: ${detail}`, + ); } /** @@ -154,14 +164,24 @@ export function mapFetchError(e: unknown): ErrorCode { // whole chain, report the top-level message. let message = e instanceof Error ? e.message : String(e); const parts: string[] = []; - for (let at: unknown = e; at instanceof Error; at = at.cause) parts.push(at.message); + for (let at: unknown = e; at instanceof Error; at = at.cause) { + parts.push(at.message); + } const m = parts.join(" | ").toLowerCase(); message = parts[0] ?? message; if (m.includes("refused")) return { kind: "connection-refused" }; - if (m.includes("dns error") || m.includes("name not resolved") || m.includes("getaddrinfo")) { - return { kind: "DNS-error", value: { rcode: undefined, infoCode: undefined } }; + if ( + m.includes("dns error") || m.includes("name not resolved") || + m.includes("getaddrinfo") + ) { + return { + kind: "DNS-error", + value: { rcode: undefined, infoCode: undefined }, + }; + } + if (m.includes("timed out") || m.includes("timeout")) { + return { kind: "connection-timeout" }; } - if (m.includes("timed out") || m.includes("timeout")) return { kind: "connection-timeout" }; if (m.includes("tls") || m.includes("certificate") || m.includes("ssl")) { return { kind: "TLS-protocol-error" }; } @@ -237,11 +257,13 @@ export interface HttpOptions { * distinct from `destination-IP-prohibited`, which names an address * judgement this fragment cannot make. */ - allowRequest?: boolean | ((request: { - url: URL; - method: string; - headers: Headers; - }) => boolean | Promise); + allowRequest?: + | boolean + | ((request: { + url: URL; + method: string; + headers: Headers; + }) => boolean | Promise); /** * Injectable transport: replaces the `fetch(request)` call `client.send` * otherwise makes directly. Default (when omitted): `globalThis.fetch`, @@ -386,10 +408,16 @@ export function http(options: HttpOptions = {}): HttpFragment { const f = internalFields([], true); for (const [name, value] of entries) { if (!FIELD_NAME.test(name)) { - throw headerError("invalid-syntax", `from-list: invalid field name ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `from-list: invalid field name ${JSON.stringify(name)}`, + ); } if (!validFieldValue(value)) { - throw headerError("invalid-syntax", `from-list: invalid value for ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `from-list: invalid value for ${JSON.stringify(name)}`, + ); } f.entries.push([name, value.slice()]); } @@ -399,7 +427,9 @@ export function http(options: HttpOptions = {}): HttpFragment { get(name: string): Uint8Array[] { onCall("fields.get"); const n = name.toLowerCase(); - return this.entries.filter(([k]) => k.toLowerCase() === n).map(([, v]) => v.slice()); + return this.entries.filter(([k]) => k.toLowerCase() === n).map(([, v]) => + v.slice() + ); } has(name: string): boolean { @@ -412,11 +442,17 @@ export function http(options: HttpOptions = {}): HttpFragment { onCall("fields.set"); requireMutableFields(this, "fields.set"); if (!FIELD_NAME.test(name)) { - throw headerError("invalid-syntax", `set: invalid field name ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `set: invalid field name ${JSON.stringify(name)}`, + ); } for (const one of value) { if (!validFieldValue(one)) { - throw headerError("invalid-syntax", `set: invalid value for ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `set: invalid value for ${JSON.stringify(name)}`, + ); } } const n = name.toLowerCase(); @@ -435,7 +471,9 @@ export function http(options: HttpOptions = {}): HttpFragment { onCall("fields.get-and-delete"); requireMutableFields(this, "fields.get-and-delete"); const n = name.toLowerCase(); - const out = this.entries.filter(([k]) => k.toLowerCase() === n).map(([, v]) => v); + const out = this.entries.filter(([k]) => k.toLowerCase() === n).map(( + [, v], + ) => v); this.entries = this.entries.filter(([k]) => k.toLowerCase() !== n); return out; } @@ -444,10 +482,16 @@ export function http(options: HttpOptions = {}): HttpFragment { onCall("fields.append"); requireMutableFields(this, "fields.append"); if (!FIELD_NAME.test(name)) { - throw headerError("invalid-syntax", `append: invalid field name ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `append: invalid field name ${JSON.stringify(name)}`, + ); } if (!validFieldValue(value)) { - throw headerError("invalid-syntax", `append: invalid value for ${JSON.stringify(name)}`); + throw headerError( + "invalid-syntax", + `append: invalid value for ${JSON.stringify(name)}`, + ); } this.entries.push([name, value.slice()]); } @@ -475,7 +519,10 @@ export function http(options: HttpOptions = {}): HttpFragment { } /** Mint a Fields without the WIT constructor's onCall. */ - function internalFields(entries: [string, Uint8Array][], mutable: boolean): Fields { + function internalFields( + entries: [string, Uint8Array][], + mutable: boolean, + ): Fields { const f = Object.create(Fields.prototype) as Fields; f.entries = entries; f.mutable = mutable; @@ -602,7 +649,10 @@ export function http(options: HttpOptions = {}): HttpFragment { setMethod(method: Method): void { onCall("request.set-method"); if (method.kind === "other" && !FIELD_NAME.test(method.value)) { - throw new ComponentException(null, "wasi:http/types: set-method: invalid method token"); + throw new ComponentException( + null, + "wasi:http/types: set-method: invalid method token", + ); } this.method = method; } @@ -613,7 +663,10 @@ export function http(options: HttpOptions = {}): HttpFragment { setPathWithQuery(pathWithQuery: string | undefined): void { onCall("request.set-path-with-query"); if (pathWithQuery !== undefined && /[ \t\r\n#]/.test(pathWithQuery)) { - throw new ComponentException(null, "wasi:http/types: set-path-with-query: invalid path"); + throw new ComponentException( + null, + "wasi:http/types: set-path-with-query: invalid path", + ); } this.pathWithQuery = pathWithQuery; } @@ -623,8 +676,14 @@ export function http(options: HttpOptions = {}): HttpFragment { } setScheme(scheme: Scheme | undefined): void { onCall("request.set-scheme"); - if (scheme?.kind === "other" && !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme.value)) { - throw new ComponentException(null, "wasi:http/types: set-scheme: invalid scheme"); + if ( + scheme?.kind === "other" && + !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme.value) + ) { + throw new ComponentException( + null, + "wasi:http/types: set-scheme: invalid scheme", + ); } this.scheme = scheme; } @@ -634,8 +693,14 @@ export function http(options: HttpOptions = {}): HttpFragment { } setAuthority(authority: string | undefined): void { onCall("request.set-authority"); - if (authority !== undefined && /[ \t\r\n/#?@]/.test(authority.replace(/@/, ""))) { - throw new ComponentException(null, "wasi:http/types: set-authority: invalid authority"); + if ( + authority !== undefined && + /[ \t\r\n/#?@]/.test(authority.replace(/@/, "")) + ) { + throw new ComponentException( + null, + "wasi:http/types: set-authority: invalid authority", + ); } this.authority = authority; } @@ -645,7 +710,10 @@ export function http(options: HttpOptions = {}): HttpFragment { } getHeaders(): Fields { onCall("request.get-headers"); - return internalFields(this.headers.entries.map(([k, v]) => [k, v.slice()]), false); + return internalFields( + this.headers.entries.map(([k, v]) => [k, v.slice()]), + false, + ); } static consumeBody( @@ -663,7 +731,10 @@ export function http(options: HttpOptions = {}): HttpFragment { if (!this.sent && !this.consumed) { this.settleTransmission({ kind: "err", - value: { kind: "internal-error", value: "request dropped without being sent" }, + value: { + kind: "internal-error", + value: "request dropped without being sent", + }, }); } } @@ -705,7 +776,10 @@ export function http(options: HttpOptions = {}): HttpFragment { } /** A response wrapping a live fetch result (internal). */ - static fromFetch(resp: globalThis.Response, options: RequestOptions | undefined): Response { + static fromFetch( + resp: globalThis.Response, + options: RequestOptions | undefined, + ): Response { const r = new Response(); r.statusCode = resp.status; r.headers = fieldsFromFetchHeaders(resp.headers); @@ -721,14 +795,22 @@ export function http(options: HttpOptions = {}): HttpFragment { } setStatusCode(statusCode: number): void { onCall("response.set-status-code"); - if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) { - throw new ComponentException(null, "wasi:http/types: set-status-code: invalid status code"); + if ( + !Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999 + ) { + throw new ComponentException( + null, + "wasi:http/types: set-status-code: invalid status code", + ); } this.statusCode = statusCode; } getHeaders(): Fields { onCall("response.get-headers"); - return internalFields(this.headers.entries.map(([k, v]) => [k, v.slice()]), false); + return internalFields( + this.headers.entries.map(([k, v]) => [k, v.slice()]), + false, + ); } static consumeBody( @@ -736,7 +818,10 @@ export function http(options: HttpOptions = {}): HttpFragment { res: FutureLike, ): [AsyncIterable, Promise] { onCall("response.consume-body"); - if (response.fetchBody !== null || (response.contents === undefined && response.trailers === undefined)) { + if ( + response.fetchBody !== null || + (response.contents === undefined && response.trailers === undefined) + ) { return consumeFetchBody(response, res); } return consumeStoredBody(response, res); @@ -751,7 +836,10 @@ export function http(options: HttpOptions = {}): HttpFragment { if (this.settleTransmission !== undefined && !this.consumed) { this.settleTransmission({ kind: "err", - value: { kind: "internal-error", value: "response dropped without being sent" }, + value: { + kind: "internal-error", + value: "response dropped without being sent", + }, }); } } @@ -789,12 +877,18 @@ export function http(options: HttpOptions = {}): HttpFragment { if (settle !== undefined) { Promise.resolve(res).then( (r) => settle(r), - () => settle({ kind: "err", value: { kind: "internal-error", value: "consumer failed" } }), + () => + settle({ + kind: "err", + value: { kind: "internal-error", value: "consumer failed" }, + }), ); } const source = (async function* (): AsyncGenerator { if (contents === undefined) return; - for await (const chunk of contents as AsyncIterable) { + for await ( + const chunk of contents as AsyncIterable + ) { yield chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); } })(); @@ -838,7 +932,11 @@ export function http(options: HttpOptions = {}): HttpFragment { settle({ kind: "err", value: e === TIMED_OUT - ? { kind: first ? "HTTP-response-timeout" : "connection-read-timeout" } + ? { + kind: first + ? "HTTP-response-timeout" + : "connection-read-timeout", + } : mapFetchError(e), }); return; @@ -909,15 +1007,23 @@ export function http(options: HttpOptions = {}): HttpFragment { : request.scheme.kind.toLowerCase(); if (scheme !== "http" && scheme !== "https") { throw httpError( - { kind: "internal-error", value: `fetch cannot carry scheme '${scheme}'` }, + { + kind: "internal-error", + value: `fetch cannot carry scheme '${scheme}'`, + }, `client.send: unsupported scheme '${scheme}'`, ); } if (request.authority === undefined) { - throw httpError({ kind: "HTTP-request-URI-invalid" }, "client.send: no authority"); + throw httpError( + { kind: "HTTP-request-URI-invalid" }, + "client.send: no authority", + ); } const path = request.pathWithQuery ?? ""; - const url = `${scheme}://${request.authority}${path.startsWith("/") || path === "" ? path : "/" + path}`; + const url = `${scheme}://${request.authority}${ + path.startsWith("/") || path === "" ? path : "/" + path + }`; const method = request.method.kind === "other" ? request.method.value.toUpperCase() @@ -929,7 +1035,10 @@ export function http(options: HttpOptions = {}): HttpFragment { headers.append(name, decoder.decode(value)); } catch (e) { throw httpError( - { kind: "internal-error", value: `header '${name}' refused by the platform` }, + { + kind: "internal-error", + value: `header '${name}' refused by the platform`, + }, `client.send: ${e}`, ); } @@ -943,8 +1052,14 @@ export function http(options: HttpOptions = {}): HttpFragment { // guest's body stream. if (allowRequest !== undefined && allowRequest !== true) { if (allowRequest === false) { - request.settleTransmission({ kind: "err", value: { kind: "HTTP-request-denied" } }); - throw httpError({ kind: "HTTP-request-denied" }, "client.send: request denied (allowRequest: false)"); + request.settleTransmission({ + kind: "err", + value: { kind: "HTTP-request-denied" }, + }); + throw httpError( + { kind: "HTTP-request-denied" }, + "client.send: request denied (allowRequest: false)", + ); } let parsed: URL; try { @@ -952,7 +1067,10 @@ export function http(options: HttpOptions = {}): HttpFragment { } catch { // A malformed authority is not a policy decision — the existing // HTTP-request-URI-invalid case at line ~858 covers this. - throw httpError({ kind: "HTTP-request-URI-invalid" }, "client.send: url could not be parsed for policy check"); + throw httpError( + { kind: "HTTP-request-URI-invalid" }, + "client.send: url could not be parsed for policy check", + ); } let allowed: boolean; try { @@ -961,15 +1079,26 @@ export function http(options: HttpOptions = {}): HttpFragment { // Fail closed: a throwing/rejecting predicate denies. The thrown // message goes into the ComponentException's DETAIL string only, // never the WIT payload. - request.settleTransmission({ kind: "err", value: { kind: "HTTP-request-denied" } }); + request.settleTransmission({ + kind: "err", + value: { kind: "HTTP-request-denied" }, + }); throw httpError( { kind: "HTTP-request-denied" }, - `client.send: allowRequest threw: ${e instanceof Error ? e.message : String(e)}`, + `client.send: allowRequest threw: ${ + e instanceof Error ? e.message : String(e) + }`, ); } if (!allowed) { - request.settleTransmission({ kind: "err", value: { kind: "HTTP-request-denied" } }); - throw httpError({ kind: "HTTP-request-denied" }, "client.send: request denied by allowRequest"); + request.settleTransmission({ + kind: "err", + value: { kind: "HTTP-request-denied" }, + }); + throw httpError( + { kind: "HTTP-request-denied" }, + "client.send: request denied by allowRequest", + ); } } @@ -985,7 +1114,10 @@ export function http(options: HttpOptions = {}): HttpFragment { // here, the request is never transmitted. const err: HttpResult = { kind: "err", value: trailersResult.value }; request.settleTransmission(err); - throw httpError(trailersResult.value, "client.send: request trailers resolved to an error"); + throw httpError( + trailersResult.value, + "client.send: request trailers resolved to an error", + ); } if (trailersResult.value !== undefined) { const err: ErrorCode = { @@ -993,7 +1125,10 @@ export function http(options: HttpOptions = {}): HttpFragment { value: "fetch cannot transmit request trailers", }; request.settleTransmission({ kind: "err", value: err }); - throw httpError(err, "client.send: request trailers are not transmissible over fetch"); + throw httpError( + err, + "client.send: request trailers are not transmissible over fetch", + ); } let resp: globalThis.Response; @@ -1012,19 +1147,26 @@ export function http(options: HttpOptions = {}): HttpFragment { // Resolved at call time (not captured at http() construction), so // a test stubbing globalThis.fetch after the fragment exists still // takes effect. - const transport = options.fetch ?? ((r: globalThis.Request) => globalThis.fetch(r)); + const transport = options.fetch ?? + ((r: globalThis.Request) => globalThis.fetch(r)); resp = await transport(req); } catch (e) { if (isComponentException(e)) { // Branded exception passthrough: the transport named the // guest-visible WIT error-code itself; do not run it through // mapFetchError's prose sniffing, and rethrow unchanged. - request.settleTransmission({ kind: "err", value: e.payload as ErrorCode }); + request.settleTransmission({ + kind: "err", + value: e.payload as ErrorCode, + }); throw e; } const code = mapFetchError(e); request.settleTransmission({ kind: "err", value: code }); - throw httpError(code, `client.send: ${e instanceof Error ? e.message : String(e)}`); + throw httpError( + code, + `client.send: ${e instanceof Error ? e.message : String(e)}`, + ); } request.settleTransmission(OK); return Response.fromFetch(resp, request.options); diff --git a/wasi/src/internal/cli_shared.ts b/wasi/src/internal/cli_shared.ts index 84b17d3..541ed30 100644 --- a/wasi/src/internal/cli_shared.ts +++ b/wasi/src/internal/cli_shared.ts @@ -25,7 +25,9 @@ export type CliByteSource = export class ExitError extends Error { constructor(readonly ok: boolean, readonly code?: number) { super( - `wasi:cli/exit#exit(${ok ? "success" : "failure"}${code === undefined ? "" : `, code ${code}`})`, + `wasi:cli/exit#exit(${ok ? "success" : "failure"}${ + code === undefined ? "" : `, code ${code}` + })`, ); this.name = "ExitError"; } diff --git a/wasi/src/internal/fs_provider.ts b/wasi/src/internal/fs_provider.ts index f8dd959..eb2baef 100644 --- a/wasi/src/internal/fs_provider.ts +++ b/wasi/src/internal/fs_provider.ts @@ -85,8 +85,19 @@ // pre-existing escaping symlinks alike are refused with `not-permitted`; // OPFS has no symlinks, so the web backend is immune by construction. -import { ComponentException, isStream, suspending, type Stream } from "@polyengine/protocol"; -import { FedInputStream, IoError, OutputStream, Pollable, SinkOutputStream } from "../io.ts"; +import { + ComponentException, + isStream, + type Stream, + suspending, +} from "@polyengine/protocol"; +import { + FedInputStream, + IoError, + OutputStream, + Pollable, + SinkOutputStream, +} from "../io.ts"; /** `wasi:filesystem/types.error-code` labels. 0.2 (enum): all of these, * bare. 0.3 (variant): all but `would-block`, as `{kind}` — this package @@ -245,7 +256,11 @@ export interface FsBackend { symlinkAt?(target: string, base: H, segments: string[]): MaybeAsync; readlinkAt?(base: H, segments: string[]): MaybeAsync; identity(h: H): MaybeAsync; - identityAt(base: H, segments: string[], follow: boolean): MaybeAsync; + identityAt( + base: H, + segments: string[], + follow: boolean, + ): MaybeAsync; isSame(a: H, b: H): MaybeAsync; } @@ -341,7 +356,10 @@ function err03(code: FsErrorCode): ComponentException<{ kind: FsErrorCode }> { type ErrShape = (code: FsErrorCode) => ComponentException; /** Chain over MaybeAsync without forcing sync backends through a tick. */ -function chain(v: MaybeAsync, f: (v: T) => MaybeAsync): MaybeAsync { +function chain( + v: MaybeAsync, + f: (v: T) => MaybeAsync, +): MaybeAsync { return v instanceof Promise ? v.then(f) : f(v); } @@ -421,9 +439,15 @@ function statValue(st: FsStat): DescriptorStatValue { type: st.type, linkCount: st.linkCount, size: st.size, - ...(st.atimeNs === undefined ? {} : { dataAccessTimestamp: nsToDatetime(st.atimeNs) }), - ...(st.mtimeNs === undefined ? {} : { dataModificationTimestamp: nsToDatetime(st.mtimeNs) }), - ...(st.ctimeNs === undefined ? {} : { statusChangeTimestamp: nsToDatetime(st.ctimeNs) }), + ...(st.atimeNs === undefined + ? {} + : { dataAccessTimestamp: nsToDatetime(st.atimeNs) }), + ...(st.mtimeNs === undefined + ? {} + : { dataModificationTimestamp: nsToDatetime(st.mtimeNs) }), + ...(st.ctimeNs === undefined + ? {} + : { statusChangeTimestamp: nsToDatetime(st.ctimeNs) }), }; } @@ -520,8 +544,10 @@ export function makeFilesystem( ): FilesystemFragment { const writable = access.writable === true; const map = (e: unknown): FsErrorCode => backend.mapError(e); - const g02 = (fn: () => MaybeAsync): MaybeAsync => guarded(map, err02, fn); - const g03 = (fn: () => MaybeAsync): MaybeAsync => guarded(map, err03, fn); + const g02 = (fn: () => MaybeAsync): MaybeAsync => + guarded(map, err02, fn); + const g03 = (fn: () => MaybeAsync): MaybeAsync => + guarded(map, err03, fn); /** A stream-facing sink/source error: an IoError subclass carrying the * code, so 0.2 stream failures downcast via filesystem-error-code. */ @@ -546,7 +572,9 @@ export function makeFilesystem( * read-only package never advertises `write`/`mutate-directory`, on * preopens or on anything `open-at` mints, so a guest that checks * flags before acting sees the same story the operations tell. */ - const flagsValue = (df: Partial): DescriptorFlagsValue => ({ + const flagsValue = ( + df: Partial, + ): DescriptorFlagsValue => ({ read: df.read === true, write: writable && df.write === true, fileIntegritySync: df.fileIntegritySync === true, @@ -555,7 +583,11 @@ export function makeFilesystem( mutateDirectory: writable && df.mutateDirectory === true, }); - const PREOPEN_FLAGS = flagsValue({ read: true, write: true, mutateDirectory: true }); + const PREOPEN_FLAGS = flagsValue({ + read: true, + write: true, + mutateDirectory: true, + }); /** The package-level grant. Refuses with the WIT `read-only` code — * distinct from the per-descriptor checks (`requireDirMutate`, @@ -661,13 +693,15 @@ export function makeFilesystem( }); /** Async sinks for SinkOutputStream: failures carry the code. */ - const asyncSink = (write: (chunk: Uint8Array) => Promise) => async (chunk: Uint8Array) => { - try { - await write(chunk); - } catch (e) { - throw streamError(e); - } - }; + const asyncSink = + (write: (chunk: Uint8Array) => Promise) => + async (chunk: Uint8Array) => { + try { + await write(chunk); + } catch (e) { + throw streamError(e); + } + }; // --- shared core ops (MaybeAsync; the track classes shape errors) ----------- @@ -709,7 +743,9 @@ export function makeFilesystem( this.#entries = entries; } readDirectoryEntry(): DirectoryEntryValue | undefined { - return this.#at < this.#entries.length ? this.#entries[this.#at++] : undefined; + return this.#at < this.#entries.length + ? this.#entries[this.#at++] + : undefined; } [Symbol.dispose](): void { this.#at = this.#entries.length; @@ -756,7 +792,9 @@ export function makeFilesystem( requireFile(this.core, err02); requireFileWrite(this.core, err02); if (backend.isSync) { - return syncWriteStream((chunk) => void backend.append(this.core.h, chunk)); + return syncWriteStream((chunk) => + void backend.append(this.core.h, chunk) + ); } return new SinkOutputStream(asyncSink(async (chunk) => { await backend.append(this.core.h, chunk); @@ -764,7 +802,11 @@ export function makeFilesystem( }) as OutputStream | SinkOutputStream; } - advise(_offset: bigint, _length: bigint, _advice: string): MaybeAsync { + advise( + _offset: bigint, + _length: bigint, + _advice: string, + ): MaybeAsync { return g02(() => requireFile(this.core, err02)); // advisory: accept and ignore } @@ -788,7 +830,10 @@ export function makeFilesystem( }); } - setTimes(atime: NewTimestampValue, mtime: NewTimestampValue): MaybeAsync { + setTimes( + atime: NewTimestampValue, + mtime: NewTimestampValue, + ): MaybeAsync { return g02(() => { requireWritable(err02); // The descriptor's OWN times: which half of the per-descriptor @@ -810,7 +855,9 @@ export function makeFilesystem( const n = Number(length); return chain( backend.read(this.core.h, n, Number(offset)), - (bytes): [Uint8Array, boolean] => [bytes, n > 0 && bytes.length === 0], + ( + bytes, + ): [Uint8Array, boolean] => [bytes, n > 0 && bytes.length === 0], ); }); } @@ -820,7 +867,10 @@ export function makeFilesystem( requireWritable(err02); requireFile(this.core, err02); requireFileWrite(this.core, err02); - return chain(backend.write(this.core.h, buffer, Number(offset)), BigInt); + return chain( + backend.write(this.core.h, buffer, Number(offset)), + BigInt, + ); }); } @@ -854,7 +904,10 @@ export function makeFilesystem( return g02(() => chain(backend.stat(this.core.h), statValue)); } - statAt(pathFlags: PathFlagsValue, path: string): MaybeAsync { + statAt( + pathFlags: PathFlagsValue, + path: string, + ): MaybeAsync { return g02(() => chain( backend.statAt( @@ -939,7 +992,8 @@ export function makeFilesystem( parsePath(path, err02), decodeOpen(pathFlags, openFlags, flags), ), - ({ handle, type }) => new Descriptor02(handle, type, flagsValue(flags)), + ({ handle, type }) => + new Descriptor02(handle, type, flagsValue(flags)), ); }); } @@ -947,7 +1001,10 @@ export function makeFilesystem( readlinkAt(path: string): MaybeAsync { return g02(() => { if (backend.readlinkAt === undefined) throw err02("unsupported"); - return backend.readlinkAt(this.core.h, requireFinal(parsePath(path, err02), err02)); + return backend.readlinkAt( + this.core.h, + requireFinal(parsePath(path, err02), err02), + ); }); } @@ -963,7 +1020,11 @@ export function makeFilesystem( }); } - renameAt(oldPath: string, newDescriptor: Descriptor02, newPath: string): MaybeAsync { + renameAt( + oldPath: string, + newDescriptor: Descriptor02, + newPath: string, + ): MaybeAsync { return g02(() => { requireWritable(err02); // Both ends (see link-at). @@ -1016,7 +1077,10 @@ export function makeFilesystem( return g02(() => chain(backend.identity(this.core.h), hashIdentity)); } - metadataHashAt(pathFlags: PathFlagsValue, path: string): MaybeAsync { + metadataHashAt( + pathFlags: PathFlagsValue, + path: string, + ): MaybeAsync { return g02(() => chain( backend.identityAt( @@ -1044,7 +1108,9 @@ export function makeFilesystem( } /** tuple, future>> */ - readViaStream(offset: bigint): [AsyncIterable, Promise] { + readViaStream( + offset: bigint, + ): [AsyncIterable, Promise] { requireFile(this.core, err03); requireRead(this.core, err03); let settle!: (r: FsResult03) => void; @@ -1064,14 +1130,21 @@ export function makeFilesystem( } /** The promise IS the future source (embedder-api.md §"Streams and futures"): drain the guest's stream. */ - async writeViaStream(data: FsByteSource, offset: bigint): Promise { + async writeViaStream( + data: FsByteSource, + offset: bigint, + ): Promise { try { requireWritable(err03); requireFile(this.core, err03); requireFileWrite(this.core, err03); let cursor = Number(offset); - for await (const chunk of data as AsyncIterable) { - const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); + for await ( + const chunk of data as AsyncIterable + ) { + const bytes = chunk instanceof Uint8Array + ? chunk + : Uint8Array.from(chunk); cursor += await backend.write(this.core.h, bytes, cursor); } return OK03; @@ -1079,7 +1152,11 @@ export function makeFilesystem( if (isStream(data)) data.drop(); // the guest's writer must not hang return { kind: "err", - value: { kind: e instanceof ComponentException ? (e.payload as { kind: FsErrorCode }).kind : map(e) }, + value: { + kind: e instanceof ComponentException + ? (e.payload as { kind: FsErrorCode }).kind + : map(e), + }, }; } } @@ -1089,8 +1166,12 @@ export function makeFilesystem( requireWritable(err03); requireFile(this.core, err03); requireFileWrite(this.core, err03); - for await (const chunk of data as AsyncIterable) { - const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); + for await ( + const chunk of data as AsyncIterable + ) { + const bytes = chunk instanceof Uint8Array + ? chunk + : Uint8Array.from(chunk); await backend.append(this.core.h, bytes); } return OK03; @@ -1098,12 +1179,20 @@ export function makeFilesystem( if (isStream(data)) data.drop(); return { kind: "err", - value: { kind: e instanceof ComponentException ? (e.payload as { kind: FsErrorCode }).kind : map(e) }, + value: { + kind: e instanceof ComponentException + ? (e.payload as { kind: FsErrorCode }).kind + : map(e), + }, }; } } - advise(_offset: bigint, _length: bigint, _advice: string): MaybeAsync { + advise( + _offset: bigint, + _length: bigint, + _advice: string, + ): MaybeAsync { return g03(() => requireFile(this.core, err03)); } @@ -1127,7 +1216,10 @@ export function makeFilesystem( }); } - setTimes(atime: NewTimestampValue, mtime: NewTimestampValue): MaybeAsync { + setTimes( + atime: NewTimestampValue, + mtime: NewTimestampValue, + ): MaybeAsync { return g03(() => { requireWritable(err03); // The descriptor's OWN times: which half of the per-descriptor @@ -1143,7 +1235,9 @@ export function makeFilesystem( } /** tuple, future>> */ - readDirectory(): MaybeAsync<[Iterable, Promise]> { + readDirectory(): MaybeAsync< + [Iterable, Promise] + > { return g03(() => { requireDir(this.core, err03); return chain( @@ -1176,7 +1270,10 @@ export function makeFilesystem( return g03(() => chain(backend.stat(this.core.h), statValue)); } - statAt(pathFlags: PathFlagsValue, path: string): MaybeAsync { + statAt( + pathFlags: PathFlagsValue, + path: string, + ): MaybeAsync { return g03(() => chain( backend.statAt( @@ -1261,7 +1358,8 @@ export function makeFilesystem( parsePath(path, err03), decodeOpen(pathFlags, openFlags, flags), ), - ({ handle, type }) => new Descriptor03(handle, type, flagsValue(flags)), + ({ handle, type }) => + new Descriptor03(handle, type, flagsValue(flags)), ); }); } @@ -1269,7 +1367,10 @@ export function makeFilesystem( readlinkAt(path: string): MaybeAsync { return g03(() => { if (backend.readlinkAt === undefined) throw err03("unsupported"); - return backend.readlinkAt(this.core.h, requireFinal(parsePath(path, err03), err03)); + return backend.readlinkAt( + this.core.h, + requireFinal(parsePath(path, err03), err03), + ); }); } @@ -1285,7 +1386,11 @@ export function makeFilesystem( }); } - renameAt(oldPath: string, newDescriptor: Descriptor03, newPath: string): MaybeAsync { + renameAt( + oldPath: string, + newDescriptor: Descriptor03, + newPath: string, + ): MaybeAsync { return g03(() => { requireWritable(err03); // Both ends (see link-at). @@ -1336,7 +1441,10 @@ export function makeFilesystem( return g03(() => chain(backend.identity(this.core.h), hashIdentity)); } - metadataHashAt(pathFlags: PathFlagsValue, path: string): MaybeAsync { + metadataHashAt( + pathFlags: PathFlagsValue, + path: string, + ): MaybeAsync { return g03(() => chain( backend.identityAt( @@ -1357,16 +1465,23 @@ export function makeFilesystem( // Async backends: mark the 0.2 track's backend-touching methods // park-capable on the freshly-minted prototype (module header; embedder-api.md §"The WASI parking kernel"). if (!backend.isSync) { - const proto = Descriptor02.prototype as unknown as Record unknown>; + const proto = Descriptor02.prototype as unknown as Record< + string, + (...a: never[]) => unknown + >; for (const name of PARKED_02) { proto[name] = suspending(proto[name]); } } const getDirectories02 = (): [Descriptor02, string][] => - preopens.map(([h, name]) => [new Descriptor02(h, "directory", PREOPEN_FLAGS), name]); + preopens.map(( + [h, name], + ) => [new Descriptor02(h, "directory", PREOPEN_FLAGS), name]); const getDirectories03 = (): [Descriptor03, string][] => - preopens.map(([h, name]) => [new Descriptor03(h, "directory", PREOPEN_FLAGS), name]); + preopens.map(( + [h, name], + ) => [new Descriptor03(h, "directory", PREOPEN_FLAGS), name]); return { imports: { diff --git a/wasi/src/internal/sockets_02.ts b/wasi/src/internal/sockets_02.ts index 6cbf52e..8917dd1 100644 --- a/wasi/src/internal/sockets_02.ts +++ b/wasi/src/internal/sockets_02.ts @@ -227,21 +227,23 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { /** Mint the wasi:io stream pair over a connection (module header). */ const connStreams = (conn: TcpConn): [FedInputStream, SinkOutputStream] => { - const input = new FedInputStream((async function* (): AsyncGenerator { - for (;;) { - let chunk: Uint8Array | null; - try { - chunk = await conn.read(TCP_RECEIVE_CHUNK); - } catch (e) { - throw new SocketIoError( - toCode02(e, "input-stream (socket read)"), - e instanceof Error ? e.message : String(e), - ); + const input = new FedInputStream( + (async function* (): AsyncGenerator { + for (;;) { + let chunk: Uint8Array | null; + try { + chunk = await conn.read(TCP_RECEIVE_CHUNK); + } catch (e) { + throw new SocketIoError( + toCode02(e, "input-stream (socket read)"), + e instanceof Error ? e.message : String(e), + ); + } + if (chunk === null) return; // peer FIN: clean stream close + if (chunk.length > 0) yield chunk; } - if (chunk === null) return; // peer FIN: clean stream close - if (chunk.length > 0) yield chunk; - } - })()); + })(), + ); const output = new SinkOutputStream(async (chunk) => { try { let at = 0; @@ -325,7 +327,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { finishBind(): void { onCall("tcp-socket.finish-bind"); if (this.#state !== "bind-in-progress") { - throw err02("not-in-progress", "tcp-socket.finish-bind: no bind in progress"); + throw err02( + "not-in-progress", + "tcp-socket.finish-bind: no bind in progress", + ); } this.#state = "bound"; // recorded; deferred to listen/connect (header) } @@ -341,7 +346,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { validateRemote(this.#family, remoteAddress, "tcp-socket.start-connect"); const connect = tcpConnect(); if (connect === undefined) { - throw err02("not-supported", "tcp-socket.start-connect: no TCP backend (no node:net)"); + throw err02( + "not-supported", + "tcp-socket.start-connect: no TCP backend (no node:net)", + ); } const local = this.#localRequest; const dial: DialState = { done: false, wait: Promise.resolve() }; @@ -373,10 +381,16 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { onCall("tcp-socket.finish-connect"); const dial = this.#dial; if (this.#state !== "connect-in-progress" || dial === undefined) { - throw err02("not-in-progress", "tcp-socket.finish-connect: no connect in progress"); + throw err02( + "not-in-progress", + "tcp-socket.finish-connect: no connect in progress", + ); } if (!dial.done) { - throw err02("would-block", "tcp-socket.finish-connect: the dial has not settled"); + throw err02( + "would-block", + "tcp-socket.finish-connect: the dial has not settled", + ); } if (dial.error !== undefined || dial.conn === undefined) { // "After a failed connection attempt ... the only valid action @@ -403,7 +417,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { } const listen = tcpListen(); if (listen === undefined) { - throw err02("not-supported", "tcp-socket.start-listen: no TCP backend (no node:net)"); + throw err02( + "not-supported", + "tcp-socket.start-listen: no TCP backend (no node:net)", + ); } const local = this.#localRequest ?? wildcard(this.#family); const listener = listen({ @@ -412,7 +429,11 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { port: local.value.port, ...(this.#backlog === undefined ? {} : { backlog: this.#backlog }), }); - const state: ListenState = { listener, settled: false, wait: Promise.resolve() }; + const state: ListenState = { + listener, + settled: false, + wait: Promise.resolve(), + }; state.wait = listener.settled().then( () => { state.settled = true; @@ -430,10 +451,16 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { onCall("tcp-socket.finish-listen"); const state = this.#listen; if (this.#state !== "listen-in-progress" || state === undefined) { - throw err02("not-in-progress", "tcp-socket.finish-listen: no listen in progress"); + throw err02( + "not-in-progress", + "tcp-socket.finish-listen: no listen in progress", + ); } if (!state.settled) { - throw err02("would-block", "tcp-socket.finish-listen: the OS bind has not settled"); + throw err02( + "would-block", + "tcp-socket.finish-listen: the OS bind has not settled", + ); } if (state.error !== undefined) { state.listener.close(); @@ -448,7 +475,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { onCall("tcp-socket.accept"); const state = this.#listen; if (this.#state !== "listening" || state === undefined) { - throw err02("invalid-state", "tcp-socket.accept: the socket is not listening"); + throw err02( + "invalid-state", + "tcp-socket.accept: the socket is not listening", + ); } let conn: TcpConn | undefined; try { @@ -476,13 +506,19 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { if (this.#state === "bound" && this.#localRequest !== undefined) { return this.#localRequest; } - throw err02("invalid-state", "tcp-socket.local-address: the socket is not bound"); + throw err02( + "invalid-state", + "tcp-socket.local-address: the socket is not bound", + ); } remoteAddress(): IpSocketAddress { onCall("tcp-socket.remote-address"); if (this.#state !== "connected" || this.#conn === undefined) { - throw err02("invalid-state", "tcp-socket.remote-address: not connected"); + throw err02( + "invalid-state", + "tcp-socket.remote-address: not connected", + ); } return parseNetAddr(this.#conn.remoteAddr); } @@ -500,7 +536,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { setListenBacklogSize(value: bigint): void { onCall("tcp-socket.set-listen-backlog-size"); if (value === 0n) { - throw err02("invalid-argument", "tcp-socket.set-listen-backlog-size: zero"); + throw err02( + "invalid-argument", + "tcp-socket.set-listen-backlog-size: zero", + ); } if (this.#state === "listening" || this.#state === "listen-in-progress") { throw err02( @@ -536,7 +575,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { setKeepAliveIdleTime(value: bigint): void { onCall("tcp-socket.set-keep-alive-idle-time"); if (value < 1n) { - throw err02("invalid-argument", "tcp-socket.set-keep-alive-idle-time: zero"); + throw err02( + "invalid-argument", + "tcp-socket.set-keep-alive-idle-time: zero", + ); } this.#keepAliveIdleNs = value; this.#applyKeepAlive(); @@ -608,7 +650,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { shutdown(shutdownType: ShutdownType): void { onCall("tcp-socket.shutdown"); if (this.#state !== "connected" || this.#conn === undefined) { - throw err02("invalid-state", "tcp-socket.shutdown: the socket is not connected"); + throw err02( + "invalid-state", + "tcp-socket.shutdown: the socket is not connected", + ); } const conn = this.#conn; if (shutdownType === "receive" || shutdownType === "both") { @@ -631,7 +676,9 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { const p = (async (): Promise => { try { let at = 0; - while (at < chunk.length) at += await conn.write(chunk.subarray(at)); + while (at < chunk.length) { + at += await conn.write(chunk.subarray(at)); + } } catch (e) { throw new SocketIoError( toCode02(e, "output-stream (socket write)"), @@ -656,7 +703,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { const conn = this.#conn; if (this.#state !== "connected" || conn === undefined) return; try { - conn.setKeepAlive(this.#keepAliveEnabled, Number(this.#keepAliveIdleNs / 1_000_000n)); + conn.setKeepAlive( + this.#keepAliveEnabled, + Number(this.#keepAliveIdleNs / 1_000_000n), + ); } catch (e) { raise02(e, "tcp-socket (applying keep-alive)"); } @@ -691,7 +741,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { const createTcpSocket = (addressFamily: IpAddressFamily): TcpSocket02 => { onCall("tcp-create-socket.create-tcp-socket"); if (tcpConnect() === undefined) { - throw err02("not-supported", "create-tcp-socket: no TCP backend (no node:net)"); + throw err02( + "not-supported", + "create-tcp-socket: no TCP backend (no node:net)", + ); } return new TcpSocket02(addressFamily); }; @@ -730,7 +783,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { validateLocal(this.#family, localAddress, "udp-socket.start-bind"); const listen = listenDatagram(); if (listen === undefined) { - throw err02("not-supported", "udp-socket.start-bind: no datagram backend (no node:dgram)"); + throw err02( + "not-supported", + "udp-socket.start-bind: no datagram backend (no node:dgram)", + ); } try { this.#conn = listen({ @@ -748,7 +804,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { finishBind(): void { onCall("udp-socket.finish-bind"); if (this.#state !== "bind-in-progress") { - throw err02("not-in-progress", "udp-socket.finish-bind: no bind in progress"); + throw err02( + "not-in-progress", + "udp-socket.finish-bind: no bind in progress", + ); } this.#state = "bound"; } @@ -770,7 +829,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { ): [IncomingDatagramStream02, OutgoingDatagramStream02] { onCall("udp-socket.stream"); if (this.#state !== "bound" || this.#conn === undefined) { - throw err02("invalid-state", "udp-socket.stream: the socket is not bound"); + throw err02( + "invalid-state", + "udp-socket.stream: the socket is not bound", + ); } const conn = this.#conn; if (remoteAddress !== undefined) { @@ -778,7 +840,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { } const wasConnected = this.#streams?.remote !== undefined; this.#generation++; - const streams: UdpStreams = { generation: this.#generation, remote: remoteAddress }; + const streams: UdpStreams = { + generation: this.#generation, + remote: remoteAddress, + }; this.#streams = streams; // OS-level (dis)connect, fire-and-forget: failures surface on the // first datagram op (doc comment above). @@ -808,7 +873,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { localAddress(): IpSocketAddress { onCall("udp-socket.local-address"); if (this.#conn === undefined) { - throw err02("invalid-state", "udp-socket.local-address: the socket is not bound"); + throw err02( + "invalid-state", + "udp-socket.local-address: the socket is not bound", + ); } return parseNetAddr(this.#conn.addr); } @@ -817,7 +885,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { onCall("udp-socket.remote-address"); const remote = this.#streams?.remote; if (remote === undefined) { - throw err02("invalid-state", "udp-socket.remote-address: the socket is not connected"); + throw err02( + "invalid-state", + "udp-socket.remote-address: the socket is not connected", + ); } return remote; } @@ -835,7 +906,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { setUnicastHopLimit(value: number): void { onCall("udp-socket.set-unicast-hop-limit"); if (value < 1) { - throw err02("invalid-argument", "udp-socket.set-unicast-hop-limit: below 1"); + throw err02( + "invalid-argument", + "udp-socket.set-unicast-hop-limit: below 1", + ); } this.#hopLimit = value; this.#applyCachedOptions(); @@ -843,13 +917,20 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { receiveBufferSize(): bigint { onCall("udp-socket.receive-buffer-size"); - return this.#bufferSize("receive", this.#recvBuffer, this.#conn?.getRecvBufferSize); + return this.#bufferSize( + "receive", + this.#recvBuffer, + this.#conn?.getRecvBufferSize, + ); } setReceiveBufferSize(value: bigint): void { onCall("udp-socket.set-receive-buffer-size"); if (value === 0n) { - throw err02("invalid-argument", "udp-socket.set-receive-buffer-size: zero"); + throw err02( + "invalid-argument", + "udp-socket.set-receive-buffer-size: zero", + ); } this.#recvBuffer = value; this.#applyCachedOptions(); @@ -857,13 +938,20 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { sendBufferSize(): bigint { onCall("udp-socket.send-buffer-size"); - return this.#bufferSize("send", this.#sendBuffer, this.#conn?.getSendBufferSize); + return this.#bufferSize( + "send", + this.#sendBuffer, + this.#conn?.getSendBufferSize, + ); } setSendBufferSize(value: bigint): void { onCall("udp-socket.set-send-buffer-size"); if (value === 0n) { - throw err02("invalid-argument", "udp-socket.set-send-buffer-size: zero"); + throw err02( + "invalid-argument", + "udp-socket.set-send-buffer-size: zero", + ); } this.#sendBuffer = value; this.#applyCachedOptions(); @@ -898,8 +986,12 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { if (conn === undefined) return; try { if (this.#hopLimit !== undefined) conn.setTtl(this.#hopLimit); - if (this.#recvBuffer !== undefined) conn.setRecvBufferSize(Number(this.#recvBuffer)); - if (this.#sendBuffer !== undefined) conn.setSendBufferSize(Number(this.#sendBuffer)); + if (this.#recvBuffer !== undefined) { + conn.setRecvBufferSize(Number(this.#recvBuffer)); + } + if (this.#sendBuffer !== undefined) { + conn.setSendBufferSize(Number(this.#sendBuffer)); + } } catch (e) { raise02(e, "udp-socket (applying cached options)"); } @@ -925,7 +1017,11 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { #streams: UdpStreams; #live: (gen: number) => boolean; - constructor(conn: DatagramConn, streams: UdpStreams, live: (gen: number) => boolean) { + constructor( + conn: DatagramConn, + streams: UdpStreams, + live: (gen: number) => boolean, + ) { this.#conn = conn; this.#streams = streams; this.#live = live; @@ -935,7 +1031,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { receive(maxResults: bigint): IncomingDatagram[] { onCall("incoming-datagram-stream.receive"); if (!this.#live(this.#streams.generation)) { - throw err02("invalid-state", "incoming-datagram-stream.receive: stale stream"); + throw err02( + "invalid-state", + "incoming-datagram-stream.receive: stale stream", + ); } const out: IncomingDatagram[] = []; const max = Number(maxResults); @@ -950,7 +1049,9 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { const source = parseNetAddr(item[1]); // The connected-mode filter backstop (module header). const remote = this.#streams.remote; - if (remote !== undefined && !sameSocketAddress(source, remote)) continue; + if (remote !== undefined && !sameSocketAddress(source, remote)) { + continue; + } out.push({ data: item[0], remoteAddress: source }); } return out; @@ -1054,12 +1155,18 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { #checkLive(what: string): void { if (!this.#live(this.#streams.generation)) { - throw err02("invalid-state", `outgoing-datagram-stream.${what}: stale stream`); + throw err02( + "invalid-state", + `outgoing-datagram-stream.${what}: stale stream`, + ); } if (this.#failure !== undefined) { const code = this.#failure; this.#failure = undefined; - throw err02(code, `outgoing-datagram-stream.${what}: an earlier send failed`); + throw err02( + code, + `outgoing-datagram-stream.${what}: an earlier send failed`, + ); } } @@ -1071,7 +1178,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { const createUdpSocket = (addressFamily: IpAddressFamily): UdpSocket02 => { onCall("udp-create-socket.create-udp-socket"); if (listenDatagram() === undefined) { - throw err02("not-supported", "create-udp-socket: no datagram backend (no node:dgram)"); + throw err02( + "not-supported", + "create-udp-socket: no datagram backend (no node:dgram)", + ); } return new UdpSocket02(addressFamily); }; @@ -1116,15 +1226,17 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { }, (e) => { const code = (e as { code?: unknown } | null)?.code; - this.#error = code === "ENOTFOUND" || code === "EAI_NONAME" || code === "ENODATA" - ? "name-unresolvable" - : code === "EAI_AGAIN" || code === "ETIMEOUT" || code === "ETIMEDOUT" - ? "temporary-resolver-failure" - : code === "EACCES" || code === "EPERM" - ? "access-denied" - : toCode02(e, "resolve-addresses") === "access-denied" - ? "access-denied" - : "unknown"; + this.#error = + code === "ENOTFOUND" || code === "EAI_NONAME" || code === "ENODATA" + ? "name-unresolvable" + : code === "EAI_AGAIN" || code === "ETIMEOUT" || + code === "ETIMEDOUT" + ? "temporary-resolver-failure" + : code === "EACCES" || code === "EPERM" + ? "access-denied" + : toCode02(e, "resolve-addresses") === "access-denied" + ? "access-denied" + : "unknown"; this.#settled = true; }, ); @@ -1138,7 +1250,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { resolveNextAddress(): IpAddress | undefined { onCall("resolve-address-stream.resolve-next-address"); if (!this.#settled) { - throw err02("would-block", "resolve-next-address: the resolver has not answered"); + throw err02( + "would-block", + "resolve-next-address: the resolver has not answered", + ); } if (this.#error !== undefined) { throw err02(this.#error, "resolve-next-address: resolution failed"); @@ -1154,7 +1269,10 @@ export function sockets02(onCall: (call: string) => void): Sockets02Fragment { [Symbol.dispose](): void {} } - const resolveAddresses = (_network: Network, name: string): ResolveAddressStream02 => { + const resolveAddresses = ( + _network: Network, + name: string, + ): ResolveAddressStream02 => { onCall("ip-name-lookup.resolve-addresses"); if (name.length === 0) { throw err02("invalid-argument", "resolve-addresses: empty name"); @@ -1208,6 +1326,11 @@ function wildcard(family: IpAddressFamily): IpSocketAddress { ? { kind: "ipv4", value: { port: 0, address: [0, 0, 0, 0] } } : { kind: "ipv6", - value: { port: 0, flowInfo: 0, address: [0, 0, 0, 0, 0, 0, 0, 0], scopeId: 0 }, + value: { + port: 0, + flowInfo: 0, + address: [0, 0, 0, 0, 0, 0, 0, 0], + scopeId: 0, + }, }; } diff --git a/wasi/src/internal/sockets_03.ts b/wasi/src/internal/sockets_03.ts index 3a4ffb8..f812321 100644 --- a/wasi/src/internal/sockets_03.ts +++ b/wasi/src/internal/sockets_03.ts @@ -51,8 +51,7 @@ export function sockets03(onCall: (call: string) => void): { UdpSocket: UdpSocketClass; TcpSocket: TcpSocketClass; resolveAddresses: (name: string) => Promise; -}{ - +} { class UdpSocket { #family: IpAddressFamily; #conn: DatagramConn | undefined; @@ -82,7 +81,10 @@ export function sockets03(onCall: (call: string) => void): { bind(localAddress: IpSocketAddress): void { onCall("udp-socket.bind"); if (this.#conn !== undefined) { - throw componentError({ kind: "invalid-state" }, "udp-socket.bind: already bound"); + throw componentError( + { kind: "invalid-state" }, + "udp-socket.bind: already bound", + ); } if (!isValidAddressFamily(this.#family, localAddress)) { throw componentError( @@ -184,7 +186,10 @@ export function sockets03(onCall: (call: string) => void): { this.#remote = undefined; } - async send(data: Uint8Array, remoteAddress: IpSocketAddress | undefined): Promise { + async send( + data: Uint8Array, + remoteAddress: IpSocketAddress | undefined, + ): Promise { onCall("udp-socket.send"); if (data.length > MAX_UDP_DATAGRAM_SIZE) { throw componentError( @@ -215,7 +220,10 @@ export function sockets03(onCall: (call: string) => void): { const sent = await this.#conn.send(data); if (sent !== data.length) { throw componentError( - { kind: "other", value: `partial send: ${sent} of ${data.length} bytes` }, + { + kind: "other", + value: `partial send: ${sent} of ${data.length} bytes`, + }, `udp-socket.send: partial send: ${sent} of ${data.length} bytes`, ); } @@ -273,7 +281,10 @@ export function sockets03(onCall: (call: string) => void): { } if (sent !== data.length) { throw componentError( - { kind: "other", value: `partial send: ${sent} of ${data.length} bytes` }, + { + kind: "other", + value: `partial send: ${sent} of ${data.length} bytes`, + }, `udp-socket.send: partial send: ${sent} of ${data.length} bytes`, ); } @@ -299,7 +310,10 @@ export function sockets03(onCall: (call: string) => void): { // connect() as a default destination only — so non-matching // sources are dropped here either way (matching what a kernel // filter would have done silently). - if (this.#remote === undefined || sameSocketAddress(source, this.#remote)) { + if ( + this.#remote === undefined || + sameSocketAddress(source, this.#remote) + ) { return [payload, source]; } } @@ -357,7 +371,11 @@ export function sockets03(onCall: (call: string) => void): { getReceiveBufferSize(): bigint { onCall("udp-socket.get-receive-buffer-size"); - return this.#bufferSize("receive", this.#recvBuffer, this.#conn?.getRecvBufferSize); + return this.#bufferSize( + "receive", + this.#recvBuffer, + this.#conn?.getRecvBufferSize, + ); } setReceiveBufferSize(value: bigint): void { @@ -374,7 +392,11 @@ export function sockets03(onCall: (call: string) => void): { getSendBufferSize(): bigint { onCall("udp-socket.get-send-buffer-size"); - return this.#bufferSize("send", this.#sendBuffer, this.#conn?.getSendBufferSize); + return this.#bufferSize( + "send", + this.#sendBuffer, + this.#conn?.getSendBufferSize, + ); } setSendBufferSize(value: bigint): void { @@ -441,7 +463,9 @@ export function sockets03(onCall: (call: string) => void): { } /** Re-detect per call: `create`'s answer must not outlive a test's stub. */ - #listen(opts: { transport: "udp"; hostname: string; port: number }): DatagramConn { + #listen( + opts: { transport: "udp"; hostname: string; port: number }, + ): DatagramConn { const listen = listenDatagram(); if (listen === undefined) { throw componentError( @@ -455,7 +479,13 @@ export function sockets03(onCall: (call: string) => void): { class TcpSocket { #family: IpAddressFamily; - #state: "unbound" | "bound" | "connecting" | "connected" | "listening" | "closed" = "unbound"; + #state: + | "unbound" + | "bound" + | "connecting" + | "connected" + | "listening" + | "closed" = "unbound"; #conn: TcpConn | undefined; #listener: TcpListener | undefined; /** The address `bind` recorded; the OS bind happens at `listen` (header). */ @@ -717,7 +747,10 @@ export function sockets03(onCall: (call: string) => void): { */ send(data: TcpSendSource): Promise { onCall("tcp-socket.send"); - if (this.#state !== "connected" || this.#sendCalled || this.#conn === undefined) { + if ( + this.#state !== "connected" || this.#sendCalled || + this.#conn === undefined + ) { dropSendSource(data); return Promise.resolve(RESULT_INVALID_STATE); } @@ -731,8 +764,12 @@ export function sockets03(onCall: (call: string) => void): { // Guest-side iteration failures (a peer trap while reading the // lifted stream) are deliberately NOT caught: they are not socket // errors, and the rejection rides the producer-failure channel. - for await (const chunk of data as AsyncIterable) { - const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); + for await ( + const chunk of data as AsyncIterable + ) { + const bytes = chunk instanceof Uint8Array + ? chunk + : Uint8Array.from(chunk); let at = 0; while (at < bytes.length) { let n: number; @@ -771,7 +808,10 @@ export function sockets03(onCall: (call: string) => void): { */ receive(): [TcpByteStream, Promise] { onCall("tcp-socket.receive"); - if (this.#state !== "connected" || this.#receiveCalled || this.#conn === undefined) { + if ( + this.#state !== "connected" || this.#receiveCalled || + this.#conn === undefined + ) { return [[], Promise.resolve(RESULT_INVALID_STATE)]; } this.#receiveCalled = true; @@ -963,7 +1003,10 @@ export function sockets03(onCall: (call: string) => void): { const conn = this.#conn; if (this.#state !== "connected" || conn === undefined) return; try { - conn.setKeepAlive(this.#keepAliveEnabled, Number(this.#keepAliveIdleNs / 1_000_000n)); + conn.setKeepAlive( + this.#keepAliveEnabled, + Number(this.#keepAliveIdleNs / 1_000_000n), + ); } catch (e) { throw mapPlatformError(e, "tcp-socket (applying keep-alive)"); } @@ -1020,7 +1063,10 @@ export function sockets03(onCall: (call: string) => void): { payload: NameLookupErrorCode, detail: string, ): ComponentException => - new ComponentException(payload, `wasi:sockets/ip-name-lookup@0.3: ${detail}`); + new ComponentException( + payload, + `wasi:sockets/ip-name-lookup@0.3: ${detail}`, + ); const toIpAddress = (hostname: string): IpAddress => { const parsed = parseNetAddr({ hostname, port: 0 }); return parsed.kind === "ipv4" @@ -1028,7 +1074,10 @@ export function sockets03(onCall: (call: string) => void): { : { kind: "ipv6", value: parsed.value.address }; }; if (name.length === 0) { - throw nameErr({ kind: "invalid-argument" }, "resolve-addresses: empty name"); + throw nameErr( + { kind: "invalid-argument" }, + "resolve-addresses: empty name", + ); } // An IP literal is already an answer (and `lookup` would hand it back // unchanged anyway — skip the resolver round-trip). @@ -1051,7 +1100,10 @@ export function sockets03(onCall: (call: string) => void): { const code = (e as { code?: unknown } | null)?.code; const message = e instanceof Error ? e.message : String(e); if (code === "ENOTFOUND" || code === "EAI_NONAME" || code === "ENODATA") { - throw nameErr({ kind: "name-unresolvable" }, `resolve-addresses: ${message}`); + throw nameErr( + { kind: "name-unresolvable" }, + `resolve-addresses: ${message}`, + ); } if (code === "EAI_AGAIN" || code === "ETIMEOUT" || code === "ETIMEDOUT") { throw nameErr( @@ -1063,18 +1115,30 @@ export function sockets03(onCall: (call: string) => void): { isDenoError(e, "NotCapable") || isDenoError(e, "PermissionDenied") || code === "EACCES" || code === "EPERM" ) { - throw nameErr({ kind: "access-denied" }, `resolve-addresses: ${message}`); + throw nameErr( + { kind: "access-denied" }, + `resolve-addresses: ${message}`, + ); } if (e instanceof TypeError) { - throw nameErr({ kind: "invalid-argument" }, `resolve-addresses: ${message}`); + throw nameErr( + { kind: "invalid-argument" }, + `resolve-addresses: ${message}`, + ); } - throw nameErr({ kind: "other", value: message }, `resolve-addresses: ${message}`); + throw nameErr( + { kind: "other", value: message }, + `resolve-addresses: ${message}`, + ); } try { return answers.map((a) => toIpAddress(a.address)); } catch (e) { const message = e instanceof Error ? e.message : String(e); - throw nameErr({ kind: "other", value: message }, `resolve-addresses: ${message}`); + throw nameErr( + { kind: "other", value: message }, + `resolve-addresses: ${message}`, + ); } }; @@ -1089,7 +1153,6 @@ export function sockets03(onCall: (call: string) => void): { }; } - /** How many bytes one tcp receive read asks the OS for. */ const TCP_RECEIVE_CHUNK = 16384; @@ -1098,14 +1161,16 @@ const TCP_RECEIVE_CHUNK = 16384; * implementors note says to skip them ("Guest code never gets to see * these failures"); everything else ends the perpetual stream. */ -const TRANSIENT_ACCEPT_FAILURES: ReadonlySet = new Set([ - "connection-aborted", - "connection-reset", - "connection-refused", - "connection-broken", - "remote-unreachable", - "timeout", -]); +const TRANSIENT_ACCEPT_FAILURES: ReadonlySet = new Set( + [ + "connection-aborted", + "connection-reset", + "connection-refused", + "connection-broken", + "remote-unreachable", + "timeout", + ], +); /** * Abandon tcp send's input when the operation fails: a lifted `Stream` diff --git a/wasi/src/internal/sockets_platform.ts b/wasi/src/internal/sockets_platform.ts index f4faa93..ae0fb71 100644 --- a/wasi/src/internal/sockets_platform.ts +++ b/wasi/src/internal/sockets_platform.ts @@ -244,7 +244,11 @@ interface NodeUdpSocket { ): void; /** Connected-mode overload (after `connect`). */ send(msg: Uint8Array, cb: (err: Error | null) => void): void; - connect(port: number, address: string, cb: (err?: Error | null) => void): void; + connect( + port: number, + address: string, + cb: (err?: Error | null) => void, + ): void; disconnect(): void; setTTL(ttl: number): void; getRecvBufferSize(): number; @@ -441,7 +445,8 @@ class NodeDatagramConn implements DatagramConn { } receiveReady(): boolean { - return this.#queue.length > 0 || this.#failure !== undefined || this.#closed; + return this.#queue.length > 0 || this.#failure !== undefined || + this.#closed; } waitReceive(): Promise { @@ -453,7 +458,10 @@ class NodeDatagramConn implements DatagramConn { this.#closed = true; // A parked receive settles as an error mapping onto `invalid-state`. this.#failWaiters( - codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket closed under a pending receive"), + codedError( + "ERR_SOCKET_DGRAM_NOT_RUNNING", + "socket closed under a pending receive", + ), ); this.#signal(); try { @@ -556,7 +564,10 @@ class NodeTcpConn implements TcpConn { if (this.#socket.destroyed) { // Locally destroyed under a pending read: never a fake EOS — maps // onto invalid-state. - throw codedError("ERR_STREAM_DESTROYED", "socket closed under a pending read"); + throw codedError( + "ERR_STREAM_DESTROYED", + "socket closed under a pending read", + ); } await new Promise((resolve) => { const done = () => { @@ -682,7 +693,11 @@ function nodeTcpListen(): TcpListen | undefined { failWaiters(args[0]); signal(); }); - server.listen({ port, host: hostname, ...(backlog === undefined ? {} : { backlog }) }); + server.listen({ + port, + host: hostname, + ...(backlog === undefined ? {} : { backlog }), + }); return { get addr(): NetAddr | null { @@ -728,7 +743,10 @@ function nodeTcpListen(): TcpListen | undefined { closed = true; signal(); failWaiters( - codedError("ERR_SERVER_NOT_RUNNING", "listener closed under a pending accept"), + codedError( + "ERR_SERVER_NOT_RUNNING", + "listener closed under a pending accept", + ), ); for (const socket of queue.splice(0, queue.length)) { socket.destroy(); // refuse queued-but-untaken connections diff --git a/wasi/src/internal/sockets_shared.ts b/wasi/src/internal/sockets_shared.ts index 6d8ad4e..b72cf10 100644 --- a/wasi/src/internal/sockets_shared.ts +++ b/wasi/src/internal/sockets_shared.ts @@ -132,9 +132,15 @@ export function parseNetAddr(addr: NetAddr): IpSocketAddress { const host = addr.hostname; if (!host.includes(":")) { const octets = host.split(".").map(Number); - if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { + if ( + octets.length !== 4 || + octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255) + ) { throw componentError( - { kind: "other", value: `unparseable IPv4 hostname ${JSON.stringify(host)}` }, + { + kind: "other", + value: `unparseable IPv4 hostname ${JSON.stringify(host)}`, + }, `unparseable IPv4 hostname ${JSON.stringify(host)}`, ); } @@ -150,7 +156,9 @@ export function parseNetAddr(addr: NetAddr): IpSocketAddress { }; } -function parseIpv6Hostname(hostname: string): { groups: Ipv6Address; scopeId: number } { +function parseIpv6Hostname( + hostname: string, +): { groups: Ipv6Address; scopeId: number } { let host = hostname; let scopeId = 0; const pct = host.indexOf("%"); @@ -162,7 +170,10 @@ function parseIpv6Hostname(hostname: string): { groups: Ipv6Address; scopeId: nu const fail = (): never => { throw componentError( - { kind: "other", value: `unparseable IPv6 hostname ${JSON.stringify(hostname)}` }, + { + kind: "other", + value: `unparseable IPv6 hostname ${JSON.stringify(hostname)}`, + }, `unparseable IPv6 hostname ${JSON.stringify(hostname)}`, ); }; @@ -186,7 +197,10 @@ function parseIpv6Hostname(hostname: string): { groups: Ipv6Address; scopeId: nu let v4Tail: [number, number] | undefined; if (last.length > 0 && last[last.length - 1].includes(".")) { const quad = last[last.length - 1].split(".").map(Number); - if (quad.length !== 4 || quad.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) fail(); + if ( + quad.length !== 4 || + quad.some((o) => !Number.isInteger(o) || o < 0 || o > 255) + ) fail(); v4Tail = [(quad[0] << 8) | quad[1], (quad[2] << 8) | quad[3]]; if (tail.length > 0) tailPieces = tail.slice(0, -1); else pieces.pop(); @@ -231,7 +245,10 @@ function isDeprecatedV4CompatibleV6(groups: Ipv6Address): boolean { * @internal — shared by the node/Deno socket backends; the public entry * point is `sockets()`. */ -export function isValidAddressFamily(family: IpAddressFamily, addr: IpSocketAddress): boolean { +export function isValidAddressFamily( + family: IpAddressFamily, + addr: IpSocketAddress, +): boolean { if (family === "ipv4") return addr.kind === "ipv4"; return addr.kind === "ipv6" && !isV4MappedV6(addr.value.address) && @@ -253,7 +270,10 @@ export function isUnspecified(addr: IpSocketAddress): boolean { * @internal — shared by the node/Deno socket backends; the public entry * point is `sockets()`. */ -export function sameSocketAddress(a: IpSocketAddress, b: IpSocketAddress): boolean { +export function sameSocketAddress( + a: IpSocketAddress, + b: IpSocketAddress, +): boolean { if (a.kind !== b.kind || a.value.port !== b.value.port) return false; return a.value.address.length === b.value.address.length && a.value.address.every((part, i) => part === b.value.address[i]); @@ -316,17 +336,32 @@ const CODE_ERRORS: Record = { * @internal — shared by the node/Deno socket backends; the public entry * point is `sockets()`. */ -export function mapPlatformError(e: unknown, what: string): ComponentException { - if (e instanceof ComponentException) return e as ComponentException; +export function mapPlatformError( + e: unknown, + what: string, +): ComponentException { + if (e instanceof ComponentException) { + return e as ComponentException; + } const message = e instanceof Error ? e.message : String(e); const err = (payload: SocketErrorCode): ComponentException => componentError(payload, `${what}: ${message}`); if (isDenoError(e, "AddrInUse")) return err({ kind: "address-in-use" }); - if (isDenoError(e, "AddrNotAvailable")) return err({ kind: "address-not-bindable" }); - if (isDenoError(e, "ConnectionRefused")) return err({ kind: "connection-refused" }); - if (isDenoError(e, "ConnectionReset")) return err({ kind: "connection-reset" }); - if (isDenoError(e, "ConnectionAborted")) return err({ kind: "connection-aborted" }); - if (isDenoError(e, "NetworkUnreachable") || isDenoError(e, "HostUnreachable")) { + if (isDenoError(e, "AddrNotAvailable")) { + return err({ kind: "address-not-bindable" }); + } + if (isDenoError(e, "ConnectionRefused")) { + return err({ kind: "connection-refused" }); + } + if (isDenoError(e, "ConnectionReset")) { + return err({ kind: "connection-reset" }); + } + if (isDenoError(e, "ConnectionAborted")) { + return err({ kind: "connection-aborted" }); + } + if ( + isDenoError(e, "NetworkUnreachable") || isDenoError(e, "HostUnreachable") + ) { return err({ kind: "remote-unreachable" }); } if (isDenoError(e, "PermissionDenied") || isDenoError(e, "NotCapable")) { @@ -340,9 +375,13 @@ export function mapPlatformError(e: unknown, what: string): ComponentException; disconnect(): void; - send(data: Uint8Array, remoteAddress: IpSocketAddress | undefined): Promise; + send( + data: Uint8Array, + remoteAddress: IpSocketAddress | undefined, + ): Promise; receive(): Promise<[Uint8Array, IpSocketAddress]>; getLocalAddress(): IpSocketAddress; getRemoteAddress(): IpSocketAddress; @@ -487,7 +528,6 @@ export interface TcpSocketClass { create(addressFamily: IpAddressFamily): TcpSocket; } - /** * The family's wildcard address, port 0 (tcp listen's implicit bind). * @@ -499,7 +539,11 @@ export function wildcardAddress(family: IpAddressFamily): IpSocketAddress { ? { kind: "ipv4", value: { port: 0, address: [0, 0, 0, 0] } } : { kind: "ipv6", - value: { port: 0, flowInfo: 0, address: [0, 0, 0, 0, 0, 0, 0, 0], scopeId: 0 }, + value: { + port: 0, + flowInfo: 0, + address: [0, 0, 0, 0, 0, 0, 0, 0], + scopeId: 0, + }, }; } - diff --git a/wasi/src/io.ts b/wasi/src/io.ts index 9c89e8e..a3f2d5f 100644 --- a/wasi/src/io.ts +++ b/wasi/src/io.ts @@ -46,7 +46,7 @@ // duck-typed async streams park through the resource types registered here. import { defineBrand, defineRealmLocal, POLLABLE } from "@polyengine/protocol"; -import { suspending, ComponentException } from "@polyengine/protocol"; +import { ComponentException, suspending } from "@polyengine/protocol"; /** The engine setTimeout ceiling: delays above 2^31-1 ms are clamped to * ~0 (node/Deno warn and fire at 1 ms). `Pollable.timer` sleeps in @@ -185,7 +185,9 @@ defineBrand(Pollable.prototype, POLLABLE); * The explicit annotation is JSR's no-slow-types rule (the `suspending` * wrapper would otherwise leave this public symbol's type inferred). */ -export const poll: (pollables: readonly Pollable[]) => number[] | Promise = suspending( +export const poll: ( + pollables: readonly Pollable[], +) => number[] | Promise = suspending( (pollables: readonly Pollable[]): number[] | Promise => { // io.wit: "poll [...] traps if the list [...] is empty". An unbranded // host throw is the embedder contract's spelling of a trap. @@ -381,7 +383,10 @@ export class FedInputStream { /** Resumes a paused feed once the buffer drains. */ #resume = (): void => {}; - constructor(source: AsyncIterable, highWater = STREAM_HIGH_WATER) { + constructor( + source: AsyncIterable, + highWater = STREAM_HIGH_WATER, + ) { this.#highWater = highWater; this.#wakePromise = new Promise((r) => (this.#wake = r)); void this.#feed(source); @@ -440,7 +445,9 @@ export class FedInputStream { throw new ComponentException({ kind: "last-operation-failed", value: this.#failure instanceof IoError ? this.#failure : new IoError( - this.#failure instanceof Error ? this.#failure.message : String(this.#failure), + this.#failure instanceof Error + ? this.#failure.message + : String(this.#failure), ), }); } @@ -531,7 +538,9 @@ export class SinkOutputStream { throw new ComponentException({ kind: "last-operation-failed", value: this.#failure instanceof IoError ? this.#failure : new IoError( - this.#failure instanceof Error ? this.#failure.message : String(this.#failure), + this.#failure instanceof Error + ? this.#failure.message + : String(this.#failure), ), }); } @@ -590,7 +599,9 @@ export class SinkOutputStream { subscribe(): Pollable { return new Pollable( - () => this.#closed || this.#failure !== undefined || this.#queued < this.#highWater, + () => + this.#closed || this.#failure !== undefined || + this.#queued < this.#highWater, () => this.#wakePromise, ); } diff --git a/wasi/src/random.ts b/wasi/src/random.ts index 56045c8..3886540 100644 --- a/wasi/src/random.ts +++ b/wasi/src/random.ts @@ -87,7 +87,9 @@ function makeRandomU64(bytes: (len: bigint) => Uint8Array): () => bigint { const DEFAULT_INSECURE_SEED: readonly [bigint, bigint] = [0n, 1n]; /** `wasi:random@0.2` + `@0.3` provider fragment (two track keys). */ -export function random(options: RandomOptions = {}): { imports: Record } { +export function random( + options: RandomOptions = {}, +): { imports: Record } { const seed = options.insecureSeed ?? DEFAULT_INSECURE_SEED; const randomBytes = makeRandomBytes(options.source); const randomU64 = makeRandomU64(randomBytes); diff --git a/wasi/tests/asserts.ts b/wasi/tests/asserts.ts index 31fcb5e..3c3aa1c 100644 --- a/wasi/tests/asserts.ts +++ b/wasi/tests/asserts.ts @@ -2,10 +2,13 @@ export function assertEq(actual: T, expected: T, msg?: string): void { const ok = Object.is(actual, expected) || - (typeof actual === "bigint" && typeof expected === "bigint" && actual === expected); + (typeof actual === "bigint" && typeof expected === "bigint" && + actual === expected); if (!ok) { throw new Error( - `${msg ?? "assertEq failed"}: expected ${describe(expected)}, got ${describe(actual)}`, + `${msg ?? "assertEq failed"}: expected ${describe(expected)}, got ${ + describe(actual) + }`, ); } } diff --git a/wasi/tests/blocking_guest_test.ts b/wasi/tests/blocking_guest_test.ts index aa4b2ec..3d0f3cb 100644 --- a/wasi/tests/blocking_guest_test.ts +++ b/wasi/tests/blocking_guest_test.ts @@ -60,7 +60,8 @@ Deno.test({ }); Deno.test({ - name: "blocking guest: poll parks, wakes, and lowers its ready list through guest realloc", + name: + "blocking guest: poll parks, wakes, and lowers its ready list through guest realloc", ignore: !ready, fn: async () => { const c = await boot(); @@ -75,7 +76,8 @@ Deno.test({ }); Deno.test({ - name: "blocking guest: jspi:false refuses the park cleanly instead of livelocking", + name: + "blocking guest: jspi:false refuses the park cleanly instead of livelocking", ignore: !ready, fn: async () => { const c = await boot({ jspi: false }); diff --git a/wasi/tests/blocking_test.ts b/wasi/tests/blocking_test.ts index 4d7a85f..a9170f2 100644 --- a/wasi/tests/blocking_test.ts +++ b/wasi/tests/blocking_test.ts @@ -5,7 +5,7 @@ // The through-a-real-guest-frame pins live in blocking_guest_test.ts. import { assertEq, assertTrue } from "./asserts.ts"; -import { Pollable, poll } from "../src/io.ts"; +import { poll, Pollable } from "../src/io.ts"; const nowNs = (): bigint => BigInt(Math.round(performance.now() * 1e6)); @@ -88,7 +88,8 @@ Deno.test("kernel: poll on an empty list traps (unbranded throw)", () => { }); Deno.test({ - name: "kernel: a timer's wait re-arms after an early fire (no resolved-promise spin)", + name: + "kernel: a timer's wait re-arms after an early fire (no resolved-promise spin)", // The re-armed 5ms sleep (and nothing to cancel it through the WIT // surface) outlives the test on purpose; timers are fire-and-forget. sanitizeOps: false, @@ -112,7 +113,8 @@ Deno.test({ }); Deno.test({ - name: "kernel: a far deadline sleeps in chunks instead of spinning on the clamp", + name: + "kernel: a far deadline sleeps in chunks instead of spinning on the clamp", // The in-flight ceiling-sized chunk sleep outlives the test on purpose. sanitizeOps: false, fn: async () => { diff --git a/wasi/tests/cli_stdio_test.ts b/wasi/tests/cli_stdio_test.ts index 6997431..17e5fe4 100644 --- a/wasi/tests/cli_stdio_test.ts +++ b/wasi/tests/cli_stdio_test.ts @@ -76,7 +76,10 @@ Deno.test("cli-stdio: the registered io prototypes carry the suspending marks (t ); } // The buffer-backed bases keep their sync fast path: no Promise returns. - assertTrue(!(new InputStream(text("x")).blockingRead(8n) instanceof Promise), "base is sync"); + assertTrue( + !(new InputStream(text("x")).blockingRead(8n) instanceof Promise), + "base is sync", + ); }); // Issue #178: `cli()`'s capture-stdin path (src/cli.ts:150) backs @@ -93,7 +96,10 @@ Deno.test("cli-stdio stdin (capture-stdin buffer): guest-visible stream reaches const stdin = stdinIface.getStdin(); assertEq(utf8(stdin.read(16n)), "hi", "serves the configured buffer"); const e = assertThrows(() => stdin.read(1n)); - assertTrue(e instanceof ComponentException, "drained capture-stdin buffer is branded closed"); + assertTrue( + e instanceof ComponentException, + "drained capture-stdin buffer is branded closed", + ); assertEq((e as ComponentException).payload.kind, "closed"); }); @@ -111,7 +117,10 @@ Deno.test("cli-stdio stdin: sync reads never park; empty-open is empty, drained- f.end(); await new Promise((r) => setTimeout(r, 0)); const e = assertThrows(() => stdin.read(1n)); - assertTrue(e instanceof ComponentException, "drained + ended = branded closed"); + assertTrue( + e instanceof ComponentException, + "drained + ended = branded closed", + ); stdin[Symbol.dispose](); }); @@ -161,7 +170,10 @@ Deno.test("cli-stdio stdin: the feed pauses past the high-water mark (no unbound const stdin = new FedInputStream(endless); await new Promise((r) => setTimeout(r, 10)); const afterFill = pulled; - assertTrue(afterFill <= 6, `the feed paused near the mark (pulled ${afterFill})`); + assertTrue( + afterFill <= 6, + `the feed paused near the mark (pulled ${afterFill})`, + ); await new Promise((r) => setTimeout(r, 10)); assertEq(pulled, afterFill, "…and stays paused while nobody reads"); stdin.read(BigInt(STREAM_HIGH_WATER)); // drain -> resume @@ -182,7 +194,11 @@ Deno.test("cli-stdio stdout: budgeted writes; blocking-flush parks until the sin }); assertEq(out.checkWrite(), BigInt(STREAM_HIGH_WATER)); out.write(text("queued")); - assertEq(out.checkWrite(), BigInt(STREAM_HIGH_WATER - 6), "the permit shrinks by queued bytes"); + assertEq( + out.checkWrite(), + BigInt(STREAM_HIGH_WATER - 6), + "the permit shrinks by queued bytes", + ); const parked = out.blockingFlush(); assertTrue(parked instanceof Promise, "undrained: the flush parks"); const sub = out.subscribe(); @@ -190,13 +206,19 @@ Deno.test("cli-stdio stdout: budgeted writes; blocking-flush parks until the sin release(); await parked; assertEq(JSON.stringify(drained), JSON.stringify(["queued"])); - assertEq(out.checkWrite(), BigInt(STREAM_HIGH_WATER), "drained: full permit back"); + assertEq( + out.checkWrite(), + BigInt(STREAM_HIGH_WATER), + "drained: full permit back", + ); out[Symbol.dispose](); }); Deno.test("cli-stdio stdout: writing past the permit is a trap (unbranded), not a stream-error", () => { const out = new SinkOutputStream(() => {}); - const e = assertThrows(() => out.write(new Uint8Array(STREAM_HIGH_WATER + 1))); + const e = assertThrows(() => + out.write(new Uint8Array(STREAM_HIGH_WATER + 1)) + ); assertTrue(!(e instanceof ComponentException), "an unbranded throw = trap"); out[Symbol.dispose](); }); @@ -209,7 +231,9 @@ Deno.test("cli-stdio stdout: a failed sink surfaces as last-operation-failed wit await new Promise((r) => setTimeout(r, 0)); const e = assertThrows(() => out.checkWrite()); assertTrue(e instanceof ComponentException, "branded stream-error"); - const payload = (e as ComponentException<{ kind: string; value?: { toDebugString(): string } }>) + const payload = (e as ComponentException< + { kind: string; value?: { toDebugString(): string } } + >) .payload; assertEq(payload.kind, "last-operation-failed"); assertTrue( @@ -235,7 +259,10 @@ Deno.test("cli-stdio fragment: both tracks; injected stdio round-trips through 0 cwd: "/tmp", isTty: { stdout: true }, }); - assertTrue("wasi:cli/stdin@0.2" in imports && "wasi:cli/stdin@0.3" in imports, "both tracks"); + assertTrue( + "wasi:cli/stdin@0.2" in imports && "wasi:cli/stdin@0.3" in imports, + "both tracks", + ); // 0.3 stdout: the guest's stream drains to the sink; the promise is the // future source (embedder-api.md §"Streams and futures"). @@ -266,16 +293,26 @@ Deno.test("cli-stdio fragment: both tracks; injected stdio round-trips through 0 getEnvironment(): [string, string][]; getInitialCwd(): string | undefined; }; - assertEq(JSON.stringify(env03.getEnvironment()), JSON.stringify([["A", "1"]])); + assertEq( + JSON.stringify(env03.getEnvironment()), + JSON.stringify([["A", "1"]]), + ); assertEq(env03.getInitialCwd(), "/tmp"); const term = imports["wasi:cli/terminal-stdout@0.3"] as { getTerminalStdout(): unknown; }; - assertTrue(term.getTerminalStdout() !== undefined, "isTty.stdout reports a terminal"); + assertTrue( + term.getTerminalStdout() !== undefined, + "isTty.stdout reports a terminal", + ); const termIn = imports["wasi:cli/terminal-stdin@0.3"] as { getTerminalStdin(): unknown; }; - assertEq(termIn.getTerminalStdin(), undefined, "stdin is not a terminal here"); + assertEq( + termIn.getTerminalStdin(), + undefined, + "stdin is not a terminal here", + ); }); Deno.test("cli-stdio exit: ExitError (branded unwind) with the 0.3 status code preserved", () => { @@ -291,7 +328,10 @@ Deno.test("cli-stdio exit: ExitError (branded unwind) with the 0.3 status code p const e1 = assertThrows(() => exit03.exit({ kind: "ok" })); assertTrue(e1 instanceof ExitError && e1.ok, "exit(ok)"); const e2 = assertThrows(() => exit03.exitWithCode(3)); - assertTrue(e2 instanceof ExitError && !(e2 as ExitError).ok, "nonzero = failure"); + assertTrue( + e2 instanceof ExitError && !(e2 as ExitError).ok, + "nonzero = failure", + ); assertEq((e2 as ExitError).code, 3, "the code rides the error"); }); @@ -299,5 +339,7 @@ Deno.test("cli-stdio defaults: the host process serves when nothing is injected" // Under Deno, `globalThis.process` exists (node compat) — construction // must succeed and register both tracks without touching the streams. const { imports } = cliStdio(); - assertTrue("wasi:cli/stdout@0.2" in imports && "wasi:cli/stdout@0.3" in imports); + assertTrue( + "wasi:cli/stdout@0.2" in imports && "wasi:cli/stdout@0.3" in imports, + ); }); diff --git a/wasi/tests/cli_test.ts b/wasi/tests/cli_test.ts index 8c0903f..9ab19fe 100644 --- a/wasi/tests/cli_test.ts +++ b/wasi/tests/cli_test.ts @@ -61,7 +61,10 @@ Deno.test("cli: get-environment / get-arguments / initial-cwd from options", () getArguments(): string[]; initialCwd(): string | undefined; }; - assertEq(JSON.stringify(env.getEnvironment()), JSON.stringify([["FOO", "bar"]])); + assertEq( + JSON.stringify(env.getEnvironment()), + JSON.stringify([["FOO", "bar"]]), + ); assertEq(JSON.stringify(env.getArguments()), JSON.stringify(["a", "b"])); assertEq(env.initialCwd(), "/work"); }); @@ -111,18 +114,26 @@ Deno.test("cli: passthrough mirrors stdout/stderr to console on both tracks; off console.error = (...a: unknown[]) => errored.push(a.join(" ")); try { const quiet = cli(); - (quiet.imports["wasi:cli/stdout@0.2"] as { getStdout(): { write(c: Uint8Array): void } }) + (quiet.imports["wasi:cli/stdout@0.2"] as { + getStdout(): { write(c: Uint8Array): void }; + }) .getStdout().write(new TextEncoder().encode("silent")); assertEq(logged.length, 0); assertEq(quiet.captured.stdoutText(), "silent"); const { imports, captured } = cli({ passthrough: true }); - (imports["wasi:cli/stdout@0.2"] as { getStdout(): { write(c: Uint8Array): void } }) + (imports["wasi:cli/stdout@0.2"] as { + getStdout(): { write(c: Uint8Array): void }; + }) .getStdout().write(new TextEncoder().encode("out2")); - (imports["wasi:cli/stderr@0.2"] as { getStderr(): { write(c: Uint8Array): void } }) + (imports["wasi:cli/stderr@0.2"] as { + getStderr(): { write(c: Uint8Array): void }; + }) .getStderr().write(new TextEncoder().encode("err2")); await (imports["wasi:cli/stderr@0.3"] as { - writeViaStream(data: AsyncIterable): Promise<{ kind: string }>; + writeViaStream( + data: AsyncIterable, + ): Promise<{ kind: string }>; }).writeViaStream((async function* () { yield new TextEncoder().encode("err3"); })()); @@ -139,7 +150,9 @@ Deno.test("cli: passthrough mirrors stdout/stderr to console on both tracks; off // --- the @0.3 track (capture impl) --------------------------------------------- Deno.test("cli@0.3: write-via-stream captures; read-via-stream serves the buffer", async () => { - const { imports, captured } = cli({ stdinBuffer: new TextEncoder().encode("in") }); + const { imports, captured } = cli({ + stdinBuffer: new TextEncoder().encode("in"), + }); const stdout = imports["wasi:cli/stdout@0.3"] as { writeViaStream(data: AsyncIterable): Promise<{ kind: string }>; }; @@ -162,11 +175,15 @@ Deno.test("cli@0.3: write-via-stream captures; read-via-stream serves the buffer Deno.test("cli@0.3: exit-with-code records; get-initial-cwd is the renamed leaf", () => { const { imports, captured } = cli({ cwd: "/w" }); - const exit = imports["wasi:cli/exit@0.3"] as { exitWithCode(code: number): void }; + const exit = imports["wasi:cli/exit@0.3"] as { + exitWithCode(code: number): void; + }; exit.exitWithCode(7); assertEq(captured.exited(), true); assertEq(captured.exitOk(), false); assertEq(captured.exitCode(), 7); - const env = imports["wasi:cli/environment@0.3"] as { getInitialCwd(): string | undefined }; + const env = imports["wasi:cli/environment@0.3"] as { + getInitialCwd(): string | undefined; + }; assertEq(env.getInitialCwd(), "/w"); }); diff --git a/wasi/tests/clocks_test.ts b/wasi/tests/clocks_test.ts index 8b324b3..8e47937 100644 --- a/wasi/tests/clocks_test.ts +++ b/wasi/tests/clocks_test.ts @@ -31,7 +31,10 @@ Deno.test("clocks@0.3: waitFor actually waits (coarse timing)", async () => { const t0 = performance.now(); await mono03.waitFor(30_000_000n); // 30ms const elapsed = performance.now() - t0; - assertTrue(elapsed >= 15, `waited at least ~half the requested duration (got ${elapsed}ms)`); + assertTrue( + elapsed >= 15, + `waited at least ~half the requested duration (got ${elapsed}ms)`, + ); }); Deno.test("clocks@0.3: waitUntil waits until the given instant", async () => { @@ -50,13 +53,19 @@ Deno.test("clocks@0.3: waitUntil waits until the given instant", async () => { Deno.test("clocks@0.3: the union provider exposes both drafts' functions on one provider", () => { const { imports } = clocks(); - const mono03 = imports["wasi:clocks/monotonic-clock@0.3"] as Record; + const mono03 = imports["wasi:clocks/monotonic-clock@0.3"] as Record< + string, + unknown + >; // iroh/experiment-mosh family: assertTrue(typeof mono03.waitFor === "function", "waitFor present"); // polymorph-websocket family: assertTrue(typeof mono03.now === "function", "now present"); assertTrue(typeof mono03.waitUntil === "function", "waitUntil present"); - assertTrue(typeof mono03.getResolution === "function", "getResolution present"); + assertTrue( + typeof mono03.getResolution === "function", + "getResolution present", + ); }); Deno.test("clocks: now() is overridable for deterministic tests", () => { @@ -73,7 +82,10 @@ Deno.test("clocks@0.3: system-clock (0.3's wall-clock reshape) — instant recor }; const t = sys.now(); assertTrue(t.seconds > 1_500_000_000n, "a plausible epoch second"); - assertTrue(t.nanoseconds >= 0 && t.nanoseconds < 1_000_000_000, "ns in range"); + assertTrue( + t.nanoseconds >= 0 && t.nanoseconds < 1_000_000_000, + "ns in range", + ); assertEq(sys.getResolution(), 1_000_000n); // Date.now() is ms-backed // The type-only types interface is a registered import target. assertTrue("wasi:clocks/types@0.3" in imports, "types@0.3 registered"); diff --git a/wasi/tests/fs_node_test.ts b/wasi/tests/fs_node_test.ts index 665e63f..4be9469 100644 --- a/wasi/tests/fs_node_test.ts +++ b/wasi/tests/fs_node_test.ts @@ -75,15 +75,22 @@ interface D03 { statAt(pf: Flags, path: string): Stat | Promise; readViaStream( off: bigint, - ): [AsyncIterable, Promise<{ kind: string; value?: { kind: string } }>]; + ): [ + AsyncIterable, + Promise<{ kind: string; value?: { kind: string } }>, + ]; writeViaStream( data: unknown, off: bigint, ): Promise<{ kind: string; value?: { kind: string } }>; - appendViaStream(data: unknown): Promise<{ kind: string; value?: { kind: string } }>; + appendViaStream( + data: unknown, + ): Promise<{ kind: string; value?: { kind: string } }>; readDirectory(): | [Iterable<{ type: string; name: string }>, Promise<{ kind: string }>] - | Promise<[Iterable<{ type: string; name: string }>, Promise<{ kind: string }>]>; + | Promise< + [Iterable<{ type: string; name: string }>, Promise<{ kind: string }>] + >; } const FOLLOW: Flags = { symlinkFollow: true }; @@ -91,8 +98,14 @@ const NOFOLLOW: Flags = {}; const RW: Flags = { read: true, write: true }; function setup(): { root02: D02; root03: D03; dir: string } { - const dir = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-node-" }); - const { imports } = filesystemNode({ preopens: { "/": dir }, writable: true }); + const dir = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-node-", + }); + const { imports } = filesystemNode({ + preopens: { "/": dir }, + writable: true, + }); const p02 = imports["wasi:filesystem/preopens@0.2"] as { getDirectories(): [D02, string][]; }; @@ -114,7 +127,10 @@ function plain(v: T, what: string): T { function errPayload(f: () => unknown): unknown { const e = assertThrows(f); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException).payload; } @@ -123,7 +139,10 @@ Deno.test("fs-node: preopens serve both tracks; flags reflect the grant", () => assertEq(plain(root02.getType(), "get-type"), "directory"); assertEq(root03.getType(), "directory"); const flags = root02.getFlags(); - assertTrue(flags.read && flags.write && flags.mutateDirectory, "preopen rw+mutate"); + assertTrue( + flags.read && flags.write && flags.mutateDirectory, + "preopen rw+mutate", + ); }); Deno.test("fs-node 0.2: open/write/read positional, sync throughout", () => { @@ -160,7 +179,10 @@ Deno.test("fs-node 0.2: via-stream read/write/append (sync streams)", () => { app.write(new TextEncoder().encode("-tail")); const src = plain(f.readViaStream(2n), "read-via-stream"); // offset 2 - assertEq(new TextDecoder().decode(plain(src.blockingRead(4n), "blocking-read")), "cdef"); + assertEq( + new TextDecoder().decode(plain(src.blockingRead(4n), "blocking-read")), + "cdef", + ); assertEq(new TextDecoder().decode(src.read(64n)), "-tail"); // Drained + EOF = the `closed` stream-error. const closed = errPayload(() => src.read(1n)); @@ -170,8 +192,14 @@ Deno.test("fs-node 0.2: via-stream read/write/append (sync streams)", () => { Deno.test("fs-node 0.2: error payloads are BARE enum strings", () => { const { root02 } = setup(); assertEq(errPayload(() => root02.statAt(FOLLOW, "missing")), "no-entry"); - assertEq(errPayload(() => root02.openAt(FOLLOW, "/etc/passwd", {}, RW)), "not-permitted"); - assertEq(errPayload(() => root02.statAt(FOLLOW, "../escape")), "not-permitted"); + assertEq( + errPayload(() => root02.openAt(FOLLOW, "/etc/passwd", {}, RW)), + "not-permitted", + ); + assertEq( + errPayload(() => root02.statAt(FOLLOW, "../escape")), + "not-permitted", + ); assertEq(errPayload(() => root02.statAt(FOLLOW, "a\0b")), "invalid"); // ".." that stays inside resolves textually. root02.createDirectoryAt("sub"); @@ -184,7 +212,10 @@ Deno.test("fs-node 0.2: directory ops + listing", () => { root02.createDirectoryAt("d"); const f = root02.openAt(FOLLOW, "d/x.txt", { create: true }, RW); f.write(new Uint8Array([1, 2, 3]), 0n); - const d = root02.openAt(FOLLOW, "d", { directory: true }, { read: true, mutateDirectory: true }); + const d = root02.openAt(FOLLOW, "d", { directory: true }, { + read: true, + mutateDirectory: true, + }); const listing = plain(d.readDirectory(), "read-directory"); const first = listing.readDirectoryEntry(); assertEq(first?.name, "x.txt"); @@ -219,9 +250,15 @@ Deno.test("fs-node 0.2: identity — metadata-hash and is-same-object", () => { assertEq(a1.isSameObject(b), false); const h1 = plain(a1.metadataHash(), "metadata-hash"); const h2 = a2.metadataHash(); - assertTrue(h1.lower === h2.lower && h1.upper === h2.upper, "same object, same hash"); + assertTrue( + h1.lower === h2.lower && h1.upper === h2.upper, + "same object, same hash", + ); const hb = b.metadataHash(); - assertTrue(h1.lower !== hb.lower || h1.upper !== hb.upper, "different objects differ"); + assertTrue( + h1.lower !== hb.lower || h1.upper !== hb.upper, + "different objects differ", + ); const hAt = root02.metadataHashAt(FOLLOW, "a.txt"); assertEq(hAt.lower, h1.lower); }); @@ -264,19 +301,30 @@ Deno.test("fs-node 0.3: stream tuples and variant error shapes", async () => { await root03.statAt(FOLLOW, "missing"); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - assertEq(((e as ComponentException).payload as { kind: string }).kind, "no-entry"); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + assertEq( + ((e as ComponentException).payload as { kind: string }).kind, + "no-entry", + ); } }); Deno.test("fs-node: filesystem-error-code downcasts our stream errors only", () => { const { imports } = filesystemNode({ - preopens: { "/": Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-node-" }) }, + preopens: { + "/": Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-node-" }), + }, }); const types = imports["wasi:filesystem/types@0.2"] as { filesystemErrorCode(err: unknown): string | undefined; }; - assertEq(types.filesystemErrorCode(new FsIoError("no-entry", "gone")), "no-entry"); + assertEq( + types.filesystemErrorCode(new FsIoError("no-entry", "gone")), + "no-entry", + ); assertEq(types.filesystemErrorCode(new Error("random")), undefined); }); @@ -292,30 +340,57 @@ Deno.test("fs-node: rejects opening through a guest-created absolute symlink", ( root02.symlinkAt("/etc", "escape"); // creation itself stays permissive assertEq(root02.readlinkAt("escape"), "/etc"); // follow=true: the chain resolves outside the root. - assertEq(errPayload(() => root02.openAt(FOLLOW, "escape/passwd", {}, { read: true })), "not-permitted"); - assertEq(errPayload(() => root02.openAt(FOLLOW, "escape", {}, { read: true })), "not-permitted"); + assertEq( + errPayload(() => + root02.openAt(FOLLOW, "escape/passwd", {}, { read: true }) + ), + "not-permitted", + ); + assertEq( + errPayload(() => root02.openAt(FOLLOW, "escape", {}, { read: true })), + "not-permitted", + ); // follow=false: the final component is refused as a symlink (ELOOP), and // an intermediate escaping component is refused outright. - assertEq(errPayload(() => root02.openAt(NOFOLLOW, "escape", {}, { read: true })), "loop"); assertEq( - errPayload(() => root02.openAt(NOFOLLOW, "escape/passwd", {}, { read: true })), + errPayload(() => root02.openAt(NOFOLLOW, "escape", {}, { read: true })), + "loop", + ); + assertEq( + errPayload(() => + root02.openAt(NOFOLLOW, "escape/passwd", {}, { read: true }) + ), "not-permitted", ); }); Deno.test("fs-node: rejects writes and creation through an escaping symlink", () => { const { root02, dir } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); root02.symlinkAt(outside, "out"); assertEq( - errPayload(() => root02.openAt(FOLLOW, "out/new.txt", { create: true }, RW)), + errPayload(() => + root02.openAt(FOLLOW, "out/new.txt", { create: true }, RW) + ), + "not-permitted", + ); + assertEq( + errPayload(() => root02.createDirectoryAt("out/d")), "not-permitted", ); - assertEq(errPayload(() => root02.createDirectoryAt("out/d")), "not-permitted"); assertEq(errPayload(() => root02.unlinkFileAt("out/x")), "not-permitted"); - assertEq(errPayload(() => root02.renameAt("out/x", root02, "y")), "not-permitted"); + assertEq( + errPayload(() => root02.renameAt("out/x", root02, "y")), + "not-permitted", + ); assertEq(errPayload(() => root02.symlinkAt("t", "out/l")), "not-permitted"); - assertTrue([...Deno.readDirSync(outside)].length === 0, "nothing was created outside"); + assertTrue( + [...Deno.readDirSync(outside)].length === 0, + "nothing was created outside", + ); // A dangling escaping link is refused too (create must not reach out). root02.symlinkAt(`${outside}/gone/x`, "dangling"); // Containment is decided before existence, so a dangling ESCAPING @@ -348,17 +423,24 @@ Deno.test("fs-node: confines but permits symlinks resolving inside the sandbox", Deno.test("fs-node: rejects opening or descending an escaping directory symlink", () => { const { root02 } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); Deno.writeTextFileSync(`${outside}/secret.txt`, "secret"); root02.symlinkAt(outside, "outdir"); // Directory opens return a path handle before openSync — they get the // same containment check, so no handle is ever minted for the escape. assertEq( - errPayload(() => root02.openAt(FOLLOW, "outdir", { directory: true }, { read: true })), + errPayload(() => + root02.openAt(FOLLOW, "outdir", { directory: true }, { read: true }) + ), "not-permitted", ); assertEq( - errPayload(() => root02.openAt(FOLLOW, "outdir/secret.txt", {}, { read: true })), + errPayload(() => + root02.openAt(FOLLOW, "outdir/secret.txt", {}, { read: true }) + ), "not-permitted", ); // ".." laundering through the escaping link is refused as well. @@ -371,9 +453,15 @@ Deno.test("fs-node: rejects opening or descending an escaping directory symlink" Deno.test("fs-node: rejects stat/readlink/metadata-hash through an escaping symlink", () => { const { root02, root03 } = setup(); root02.symlinkAt("/etc", "e"); - assertEq(errPayload(() => root02.statAt(FOLLOW, "e/passwd")), "not-permitted"); + assertEq( + errPayload(() => root02.statAt(FOLLOW, "e/passwd")), + "not-permitted", + ); assertEq(errPayload(() => root02.statAt(FOLLOW, "e")), "not-permitted"); - assertEq(errPayload(() => root02.metadataHashAt(FOLLOW, "e")), "not-permitted"); + assertEq( + errPayload(() => root02.metadataHashAt(FOLLOW, "e")), + "not-permitted", + ); assertEq(errPayload(() => root02.readlinkAt("e/passwd")), "not-permitted"); // nofollow stat sees the link itself — that entry IS inside the sandbox. assertEq(root02.statAt(NOFOLLOW, "e").type, "symbolic-link"); @@ -387,8 +475,14 @@ Deno.test("fs-node 0.3: rejects escaping symlink resolution with the variant sha await root03.statAt(FOLLOW, "e/passwd"); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - assertEq(((e as ComponentException).payload as { kind: string }).kind, "not-permitted"); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + assertEq( + ((e as ComponentException).payload as { kind: string }).kind, + "not-permitted", + ); } }); @@ -400,7 +494,10 @@ Deno.test("fs-node 0.3: rejects escaping symlink resolution with the variant sha Deno.test("fs-node: rejects a '..' chain laundered through an escaping symlink", () => { const { root02 } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); Deno.mkdirSync(`${outside}/inner`); Deno.writeTextFileSync(`${outside}/secret.txt`, "secret payload"); root02.symlinkAt(`${outside}/inner`, "esc"); // creation stays permissive @@ -408,7 +505,10 @@ Deno.test("fs-node: rejects a '..' chain laundered through an escaping symlink", // the sandbox, physically it does not. root02.symlinkAt("esc/../secret.txt", "L"); assertEq(errPayload(() => root02.statAt(FOLLOW, "L")), "not-permitted"); - assertEq(errPayload(() => root02.metadataHashAt(FOLLOW, "L")), "not-permitted"); + assertEq( + errPayload(() => root02.metadataHashAt(FOLLOW, "L")), + "not-permitted", + ); assertEq( errPayload(() => root02.openAt(FOLLOW, "L", {}, { read: true })), "not-permitted", @@ -435,14 +535,19 @@ Deno.test("fs-node: rejects a '..' chain laundered through an escaping symlink", // can only ever climb the guest-visible tree, never out of it — the // hazard was ".." inside symlink TARGETS, which parsePath never sees. assertEq( - errPayload(() => root02.openAt(FOLLOW, "esc/../secret.txt", {}, { read: true })), + errPayload(() => + root02.openAt(FOLLOW, "esc/../secret.txt", {}, { read: true }) + ), "no-entry", ); }); Deno.test("fs-node: rejects creating through a dangling '..'-laundered symlink", () => { const { root02 } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); Deno.mkdirSync(`${outside}/inner`); root02.symlinkAt(`${outside}/inner`, "esc"); root02.symlinkAt("esc/../planted.txt", "D"); // dangles, outside @@ -472,25 +577,36 @@ Deno.test("fs-node: confines but permits an in-sandbox '..' link target", () => Deno.test("fs-node: rejects listing and descending a laundered escaping directory link", () => { const { root02 } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); Deno.mkdirSync(`${outside}/target`); Deno.writeTextFileSync(`${outside}/target/f.txt`, "x"); root02.symlinkAt(`${outside}/target`, "esc"); root02.symlinkAt("esc/../target", "LD"); // a DIRECTORY, laundered assertEq( - errPayload(() => root02.openAt(FOLLOW, "LD", { directory: true }, { read: true })), + errPayload(() => + root02.openAt(FOLLOW, "LD", { directory: true }, { read: true }) + ), "not-permitted", ); assertEq( errPayload(() => root02.openAt(FOLLOW, "LD/f.txt", {}, { read: true })), "not-permitted", ); - assertEq(errPayload(() => root02.statAt(FOLLOW, "LD/f.txt")), "not-permitted"); + assertEq( + errPayload(() => root02.statAt(FOLLOW, "LD/f.txt")), + "not-permitted", + ); }); Deno.test("fs-node: confines symlink targets that are bare '..' components", () => { const { root02 } = setup(); - const outside = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-outside-" }); + const outside = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-outside-", + }); Deno.mkdirSync(`${outside}/inner`); root02.createDirectoryAt("sub"); root02.symlinkAt(`${outside}/inner`, "esc"); @@ -498,7 +614,9 @@ Deno.test("fs-node: confines symlink targets that are bare '..' components", () root02.symlinkAt("esc/..", "up"); assertEq(errPayload(() => root02.statAt(FOLLOW, "up")), "not-permitted"); assertEq( - errPayload(() => root02.openAt(FOLLOW, "up", { directory: true }, { read: true })), + errPayload(() => + root02.openAt(FOLLOW, "up", { directory: true }, { read: true }) + ), "not-permitted", ); // A climb out of the ROOT is refused (parsePath's underflow rule, diff --git a/wasi/tests/fs_readonly_test.ts b/wasi/tests/fs_readonly_test.ts index 27722d1..9c1684d 100644 --- a/wasi/tests/fs_readonly_test.ts +++ b/wasi/tests/fs_readonly_test.ts @@ -32,7 +32,12 @@ import { ComponentException } from "@polyengine/protocol"; import { filesystemNode } from "../src/filesystem_node.ts"; import { filesystemWeb } from "../src/filesystem_web.ts"; import { FakeDirectoryHandle } from "./support/opfs_fake.ts"; -import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; +import { + assertEq, + assertRejects, + assertThrows, + assertTrue, +} from "./asserts.ts"; type Flags = Record; @@ -74,7 +79,12 @@ interface D03 { appendViaStream(data: unknown): Promise; setSize(n: bigint): void | Promise; setTimes(a: unknown, m: unknown): void | Promise; - setTimesAt(pf: Flags, p: string, a: unknown, m: unknown): void | Promise; + setTimesAt( + pf: Flags, + p: string, + a: unknown, + m: unknown, + ): void | Promise; createDirectoryAt(p: string): void | Promise; removeDirectoryAt(p: string): void | Promise; unlinkFileAt(p: string): void | Promise; @@ -92,7 +102,10 @@ const NOW = { kind: "now" }; /** A tree with content to mutate: the read-only cases must find their * targets present (so a refusal is a refusal, not a missing file). */ function seedTree(): string { - const dir = Deno.makeTempDirSync({ dir: "/tmp", prefix: "polyengine-fs-ro-" }); + const dir = Deno.makeTempDirSync({ + dir: "/tmp", + prefix: "polyengine-fs-ro-", + }); Deno.writeTextFileSync(`${dir}/seed.txt`, "seed"); Deno.writeTextFileSync(`${dir}/other.txt`, "other"); Deno.mkdirSync(`${dir}/sub`); @@ -120,13 +133,19 @@ function setup(writable: boolean): Setup { function payload(f: () => unknown): unknown { const e = assertThrows(f); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException).payload; } async function rejectedPayload(f: () => unknown): Promise { const e = await assertRejects(f); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException).payload; } @@ -150,12 +169,21 @@ function assertReadOnlyResult03(r: Res03): void { /** Leaves reachable from the preopen directory descriptor alone. */ const DIR_LEAVES_02: [string, (s: Setup) => unknown][] = [ - ["set-times-at", ({ root02 }) => root02.setTimesAt(FOLLOW, "seed.txt", NOW, NOW)], + [ + "set-times-at", + ({ root02 }) => root02.setTimesAt(FOLLOW, "seed.txt", NOW, NOW), + ], ["create-directory-at", ({ root02 }) => root02.createDirectoryAt("made")], ["remove-directory-at", ({ root02 }) => root02.removeDirectoryAt("empty")], ["unlink-file-at", ({ root02 }) => root02.unlinkFileAt("seed.txt")], - ["rename-at", ({ root02 }) => root02.renameAt("seed.txt", root02, "moved.txt")], - ["link-at", ({ root02 }) => root02.linkAt({}, "seed.txt", root02, "linked.txt")], + [ + "rename-at", + ({ root02 }) => root02.renameAt("seed.txt", root02, "moved.txt"), + ], + [ + "link-at", + ({ root02 }) => root02.linkAt({}, "seed.txt", root02, "linked.txt"), + ], ["symlink-at", ({ root02 }) => root02.symlinkAt("seed.txt", "alias.txt")], ]; @@ -195,16 +223,31 @@ for (const [name, op] of FILE_LEAVES_02) { Deno.test("fs-readonly 0.2: open-at refuses create/truncate/exclusive and write flags", () => { const { root02 } = setup(false); - assertReadOnly02(() => root02.openAt(FOLLOW, "new.txt", { create: true }, READ)); - assertReadOnly02(() => root02.openAt(FOLLOW, "seed.txt", { truncate: true }, READ)); - assertReadOnly02(() => root02.openAt(FOLLOW, "new.txt", { exclusive: true }, READ)); + assertReadOnly02(() => + root02.openAt(FOLLOW, "new.txt", { create: true }, READ) + ); + assertReadOnly02(() => + root02.openAt(FOLLOW, "seed.txt", { truncate: true }, READ) + ); + assertReadOnly02(() => + root02.openAt(FOLLOW, "new.txt", { exclusive: true }, READ) + ); assertReadOnly02(() => root02.openAt(FOLLOW, "seed.txt", {}, RW)); assertReadOnly02(() => - root02.openAt(FOLLOW, "sub", { directory: true }, { read: true, mutateDirectory: true }) + root02.openAt(FOLLOW, "sub", { directory: true }, { + read: true, + mutateDirectory: true, + }) ); // ...and the read-only opens it must still allow. - assertEq(root02.openAt(FOLLOW, "seed.txt", {}, READ).getType(), "regular-file"); - assertEq(root02.openAt(FOLLOW, "sub", { directory: true }, READ).getType(), "directory"); + assertEq( + root02.openAt(FOLLOW, "seed.txt", {}, READ).getType(), + "regular-file", + ); + assertEq( + root02.openAt(FOLLOW, "sub", { directory: true }, READ).getType(), + "directory", + ); }); Deno.test("fs-readonly 0.2: non-mutating leaves stay available", () => { @@ -220,12 +263,21 @@ Deno.test("fs-readonly 0.2: non-mutating leaves stay available", () => { // --- 0.3: the same enumeration, variant-shaped errors ----------------------------- const DIR_LEAVES_03: [string, (s: Setup) => unknown][] = [ - ["set-times-at", ({ root03 }) => root03.setTimesAt(FOLLOW, "seed.txt", NOW, NOW)], + [ + "set-times-at", + ({ root03 }) => root03.setTimesAt(FOLLOW, "seed.txt", NOW, NOW), + ], ["create-directory-at", ({ root03 }) => root03.createDirectoryAt("made")], ["remove-directory-at", ({ root03 }) => root03.removeDirectoryAt("empty")], ["unlink-file-at", ({ root03 }) => root03.unlinkFileAt("seed.txt")], - ["rename-at", ({ root03 }) => root03.renameAt("seed.txt", root03, "moved.txt")], - ["link-at", ({ root03 }) => root03.linkAt({}, "seed.txt", root03, "linked.txt")], + [ + "rename-at", + ({ root03 }) => root03.renameAt("seed.txt", root03, "moved.txt"), + ], + [ + "link-at", + ({ root03 }) => root03.linkAt({}, "seed.txt", root03, "linked.txt"), + ], ["symlink-at", ({ root03 }) => root03.symlinkAt("seed.txt", "alias.txt")], ]; @@ -275,16 +327,28 @@ Deno.test("fs-readonly 0.3: append-via-stream refuses with read-only", async () Deno.test("fs-readonly 0.3: the stream writers work with writable: true", async () => { const s = setup(true); const f = await s.root03.openAt(FOLLOW, "seed.txt", {}, RW); - assertEq((await f.writeViaStream([new TextEncoder().encode("A")], 0n)).kind, "ok"); - assertEq((await f.appendViaStream([new TextEncoder().encode("B")])).kind, "ok"); + assertEq( + (await f.writeViaStream([new TextEncoder().encode("A")], 0n)).kind, + "ok", + ); + assertEq( + (await f.appendViaStream([new TextEncoder().encode("B")])).kind, + "ok", + ); assertEq(Deno.readTextFileSync(`${s.dir}/seed.txt`), "AeedB"); }); Deno.test("fs-readonly 0.3: open-at refuses create/truncate/exclusive and write", async () => { const { root03 } = setup(false); - assertReadOnly03(() => root03.openAt(FOLLOW, "new.txt", { create: true }, READ)); - assertReadOnly03(() => root03.openAt(FOLLOW, "seed.txt", { truncate: true }, READ)); - assertReadOnly03(() => root03.openAt(FOLLOW, "new.txt", { exclusive: true }, READ)); + assertReadOnly03(() => + root03.openAt(FOLLOW, "new.txt", { create: true }, READ) + ); + assertReadOnly03(() => + root03.openAt(FOLLOW, "seed.txt", { truncate: true }, READ) + ); + assertReadOnly03(() => + root03.openAt(FOLLOW, "new.txt", { exclusive: true }, READ) + ); assertReadOnly03(() => root03.openAt(FOLLOW, "seed.txt", {}, RW)); assertEq((await root03.statAt(FOLLOW, "seed.txt")).type, "regular-file"); }); @@ -296,11 +360,17 @@ Deno.test("fs-readonly: preopen descriptors advertise no write/mutate-directory" for (const flags of [ro.root02.getFlags(), ro.root03.getFlags()]) { assertTrue(flags.read, "read-only preopens are still readable"); assertTrue(!flags.write, "read-only preopen must not advertise write"); - assertTrue(!flags.mutateDirectory, "read-only preopen must not advertise mutate-directory"); + assertTrue( + !flags.mutateDirectory, + "read-only preopen must not advertise mutate-directory", + ); } const rw = setup(true); for (const flags of [rw.root02.getFlags(), rw.root03.getFlags()]) { - assertTrue(flags.read && flags.write && flags.mutateDirectory, "writable preopen: rw+mutate"); + assertTrue( + flags.read && flags.write && flags.mutateDirectory, + "writable preopen: rw+mutate", + ); } }); @@ -310,7 +380,10 @@ Deno.test("fs-readonly: get-flags on an opened descriptor tells the same story", // flags it hands back can never carry write. const f = ro.root02.openAt(FOLLOW, "seed.txt", {}, READ); const flags = f.getFlags(); - assertTrue(flags.read && !flags.write && !flags.mutateDirectory, "read-only open: read only"); + assertTrue( + flags.read && !flags.write && !flags.mutateDirectory, + "read-only open: read only", + ); const rw = setup(true); const g = rw.root02.openAt(FOLLOW, "seed.txt", {}, RW); @@ -324,7 +397,10 @@ Deno.test("fs-readonly: link-at/rename-at/symlink-at bridge nowhere by default", const sub = root02.openAt(FOLLOW, "sub", { directory: true }, READ); // Every combination of source/destination descriptor: both ends refuse, // so there is no "writable cell" to reach from a read-only one. - const pairs = [[root02, root02], [root02, sub], [sub, root02], [sub, sub]] as [D02, D02][]; + const pairs = [[root02, root02], [root02, sub], [sub, root02], [ + sub, + sub, + ]] as [D02, D02][]; for (const [a, b] of pairs) { assertReadOnly02(() => a.renameAt("seed.txt", b, "moved.txt")); assertReadOnly02(() => a.linkAt({}, "seed.txt", b, "linked.txt")); @@ -356,10 +432,22 @@ const DIR_FLAG_LEAVES: [string, (root: D02, ro: D02) => unknown][] = [ ["unlink-file-at", (_r, ro) => ro.unlinkFileAt("inner.txt")], ["set-times-at", (_r, ro) => ro.setTimesAt(FOLLOW, "inner.txt", NOW, NOW)], ["symlink-at", (_r, ro) => ro.symlinkAt("inner.txt", "alias")], - ["rename-at (source side)", (root, ro) => ro.renameAt("inner.txt", root, "pulled.txt")], - ["rename-at (destination side)", (root, ro) => root.renameAt("seed.txt", ro, "pushed.txt")], - ["link-at (source side)", (root, ro) => ro.linkAt({}, "inner.txt", root, "pulled.txt")], - ["link-at (destination side)", (root, ro) => root.linkAt({}, "seed.txt", ro, "pushed.txt")], + [ + "rename-at (source side)", + (root, ro) => ro.renameAt("inner.txt", root, "pulled.txt"), + ], + [ + "rename-at (destination side)", + (root, ro) => root.renameAt("seed.txt", ro, "pushed.txt"), + ], + [ + "link-at (source side)", + (root, ro) => ro.linkAt({}, "inner.txt", root, "pulled.txt"), + ], + [ + "link-at (destination side)", + (root, ro) => root.linkAt({}, "seed.txt", ro, "pushed.txt"), + ], ]; /** Seed `sub` with the targets DIR_FLAG_LEAVES names, then open it with @@ -368,7 +456,11 @@ function subDescriptor(df: Flags): { root02: D02; sub: D02; dir: string } { const { root02, dir } = setup(true); Deno.writeTextFileSync(`${dir}/sub/inner.txt`, "inner"); Deno.mkdirSync(`${dir}/sub/nested`); - return { root02, sub: root02.openAt(FOLLOW, "sub", { directory: true }, df), dir }; + return { + root02, + sub: root02.openAt(FOLLOW, "sub", { directory: true }, df), + dir, + }; } for (const [name, op] of DIR_FLAG_LEAVES) { @@ -394,7 +486,10 @@ Deno.test("fs-descriptor-flags 0.2: a mutate-directory descriptor still works", }); rw.createDirectoryAt("made"); rw.unlinkFileAt("inner.txt"); - assertEq([...Deno.readDirSync(`${dir}/sub`)].map((e) => e.name).join(","), "made"); + assertEq( + [...Deno.readDirSync(`${dir}/sub`)].map((e) => e.name).join(","), + "made", + ); }); // --- open-at escalation: "obtain another handle which would permit any of those" --- @@ -406,18 +501,32 @@ Deno.test("fs-descriptor-flags 0.2: open-at refuses to escalate through a read-o const ro = root02.openAt(FOLLOW, "sub", { directory: true }, READ); assertEq(payload(() => ro.openAt(FOLLOW, "inner.txt", {}, RW)), "read-only"); assertEq( - payload(() => ro.openAt(FOLLOW, "nested", { directory: true }, { - read: true, - mutateDirectory: true, - })), + payload(() => + ro.openAt(FOLLOW, "nested", { directory: true }, { + read: true, + mutateDirectory: true, + }) + ), + "read-only", + ); + assertEq( + payload(() => ro.openAt(FOLLOW, "new.txt", { create: true }, READ)), + "read-only", + ); + assertEq( + payload(() => ro.openAt(FOLLOW, "inner.txt", { truncate: true }, READ)), + "read-only", + ); + assertEq( + payload(() => ro.openAt(FOLLOW, "new.txt", { exclusive: true }, READ)), "read-only", ); - assertEq(payload(() => ro.openAt(FOLLOW, "new.txt", { create: true }, READ)), "read-only"); - assertEq(payload(() => ro.openAt(FOLLOW, "inner.txt", { truncate: true }, READ)), "read-only"); - assertEq(payload(() => ro.openAt(FOLLOW, "new.txt", { exclusive: true }, READ)), "read-only"); // Plain reads through the same descriptor stay allowed. assertEq(ro.openAt(FOLLOW, "inner.txt", {}, READ).getType(), "regular-file"); - assertEq(ro.openAt(FOLLOW, "nested", { directory: true }, READ).getType(), "directory"); + assertEq( + ro.openAt(FOLLOW, "nested", { directory: true }, READ).getType(), + "directory", + ); }); Deno.test("fs-descriptor-flags 0.2: a mutate-directory child is not escalation-blocked", () => { @@ -430,7 +539,10 @@ Deno.test("fs-descriptor-flags 0.2: a mutate-directory child is not escalation-b assertEq(rw.openAt(FOLLOW, "inner.txt", {}, RW).getType(), "regular-file"); rw.openAt(FOLLOW, "fresh.txt", { create: true }, RW); rw.createDirectoryAt("made"); - assertTrue(Deno.statSync(`${dir}/sub/made`).isDirectory, "the child dir was created"); + assertTrue( + Deno.statSync(`${dir}/sub/made`).isDirectory, + "the child dir was created", + ); }); // --- set-times: the type dispatch ------------------------------------------------- @@ -454,7 +566,10 @@ Deno.test("fs-descriptor-flags 0.2: path ops on a file descriptor say not-direct assertEq(payload(() => f.unlinkFileAt("other.txt")), "not-directory"); // Two-descriptor op, wrong-kind DESTINATION: still not-directory, and // reported before any permission verdict. - assertEq(payload(() => root02.renameAt("seed.txt", f, "moved.txt")), "not-directory"); + assertEq( + payload(() => root02.renameAt("seed.txt", f, "moved.txt")), + "not-directory", + ); }); Deno.test("fs-descriptor-flags 0.2: a write-only file descriptor still writes", () => { @@ -470,18 +585,24 @@ Deno.test("fs-descriptor-flags 0.2: a write-only file descriptor still writes", Deno.test("fs-descriptor-flags 0.3: the same refusals, variant-shaped", async () => { const { root03, dir } = setup(true); Deno.writeTextFileSync(`${dir}/sub/inner.txt`, "inner"); - const ro = (await root03.openAt(FOLLOW, "sub", { directory: true }, READ)) as D03; + const ro = + (await root03.openAt(FOLLOW, "sub", { directory: true }, READ)) as D03; assertReadOnly03(() => ro.createDirectoryAt("made")); assertReadOnly03(() => ro.openAt(FOLLOW, "inner.txt", {}, RW)); // set-times dispatch: directory -> read-only, file -> bad-descriptor. assertReadOnly03(() => ro.setTimes(NOW, NOW)); const roFile = (await root03.openAt(FOLLOW, "seed.txt", {}, READ)) as D03; - assertEq((payload(() => roFile.setTimes(NOW, NOW)) as { kind: string }).kind, "bad-descriptor"); + assertEq( + (payload(() => roFile.setTimes(NOW, NOW)) as { kind: string }).kind, + "bad-descriptor", + ); }); // --- the async backend inherits the same refusals --------------------------------- -function setupWeb(writable: boolean): { root02: D02; root03: D03; fake: FakeDirectoryHandle } { +function setupWeb( + writable: boolean, +): { root02: D02; root03: D03; fake: FakeDirectoryHandle } { const fake = new FakeDirectoryHandle(""); const { imports } = filesystemWeb({ preopens: { "/": fake }, writable }); const [[root02]] = (imports["wasi:filesystem/preopens@0.2"] as { @@ -498,13 +619,23 @@ Deno.test("fs-readonly (web backend): refusals come from the provider", async () // 0.2 on an async backend: the methods are suspending-marked, so the // refusal surfaces as a rejection rather than a throw. assertEq( - await rejectedPayload(() => root02.openAt(FOLLOW, "x.txt", { create: true }, RW)), + await rejectedPayload(() => + root02.openAt(FOLLOW, "x.txt", { create: true }, RW) + ), + "read-only", + ); + assertEq( + await rejectedPayload(() => root02.createDirectoryAt("d")), "read-only", ); - assertEq(await rejectedPayload(() => root02.createDirectoryAt("d")), "read-only"); - assertEq(await rejectedPayload(() => root02.unlinkFileAt("x.txt")), "read-only"); assertEq( - ((await rejectedPayload(() => root03.createDirectoryAt("d"))) as { kind: string }).kind, + await rejectedPayload(() => root02.unlinkFileAt("x.txt")), + "read-only", + ); + assertEq( + ((await rejectedPayload(() => root03.createDirectoryAt("d"))) as { + kind: string; + }).kind, "read-only", ); assertTrue(!root02.getFlags().write, "web preopen: no write when read-only"); @@ -512,7 +643,20 @@ Deno.test("fs-readonly (web backend): refusals come from the provider", async () Deno.test("fs-readonly (web backend): writable: true restores the writes", async () => { const { root02, fake } = setupWeb(true); - const f = await (root02.openAt(FOLLOW, "x.txt", { create: true }, RW) as unknown as Promise); - assertEq(await (f.write(new TextEncoder().encode("hi"), 0n) as unknown as Promise), 2n); - assertTrue((await fake.getFileHandle("x.txt")) !== undefined, "the file exists"); + const f = await (root02.openAt( + FOLLOW, + "x.txt", + { create: true }, + RW, + ) as unknown as Promise); + assertEq( + await (f.write(new TextEncoder().encode("hi"), 0n) as unknown as Promise< + bigint + >), + 2n, + ); + assertTrue( + (await fake.getFileHandle("x.txt")) !== undefined, + "the file exists", + ); }); diff --git a/wasi/tests/fs_web_test.ts b/wasi/tests/fs_web_test.ts index 922e18d..6e9c8ec 100644 --- a/wasi/tests/fs_web_test.ts +++ b/wasi/tests/fs_web_test.ts @@ -45,7 +45,9 @@ interface D02 { statAt(pf: Flags, path: string): Promise; readViaStream(off: bigint): InStream02; writeViaStream(off: bigint): OutStream02; - readDirectory(): Promise<{ readDirectoryEntry(): { name: string } | undefined }>; + readDirectory(): Promise< + { readDirectoryEntry(): { name: string } | undefined } + >; createDirectoryAt(p: string): Promise; removeDirectoryAt(p: string): Promise; unlinkFileAt(p: string): Promise; @@ -59,8 +61,13 @@ interface D02 { interface D03 { openAt(pf: Flags, path: string, of: Flags, df: Flags): Promise; statAt(pf: Flags, path: string): Promise; - readViaStream(off: bigint): [AsyncIterable, Promise<{ kind: string }>]; - writeViaStream(data: unknown, off: bigint): Promise<{ kind: string; value?: { kind: string } }>; + readViaStream( + off: bigint, + ): [AsyncIterable, Promise<{ kind: string }>]; + writeViaStream( + data: unknown, + off: bigint, + ): Promise<{ kind: string; value?: { kind: string } }>; } const FOLLOW: Flags = { symlinkFollow: true }; @@ -68,7 +75,10 @@ const RW: Flags = { read: true, write: true }; function setup(): { root02: D02; root03: D03; fake: FakeDirectoryHandle } { const fake = new FakeDirectoryHandle(""); - const { imports } = filesystemWeb({ preopens: { "/": fake }, writable: true }); + const { imports } = filesystemWeb({ + preopens: { "/": fake }, + writable: true, + }); const [[root02]] = (imports["wasi:filesystem/preopens@0.2"] as { getDirectories(): [D02, string][]; }).getDirectories(); @@ -80,14 +90,19 @@ function setup(): { root02: D02; root03: D03; fake: FakeDirectoryHandle } { async function rejectedPayload(f: () => unknown): Promise { const e = await assertRejects(f); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException).payload; } Deno.test("fs-web: 0.2 descriptor methods carry the suspending marks", () => { const { root02 } = setup(); const proto = Object.getPrototypeOf(root02) as Record; - for (const name of ["openAt", "stat", "statAt", "read", "write", "readDirectory"]) { + for ( + const name of ["openAt", "stat", "statAt", "read", "write", "readDirectory"] + ) { assertTrue(isSuspending(proto[name]), `${name} must be suspending-marked`); } // The node backend's prototype must NOT be marked (callback-mode pin) — @@ -110,11 +125,15 @@ Deno.test("fs-web 0.2: open/write/read, parking path", async () => { const st = await f.stat(); assertEq(st.type, "regular-file"); assertEq(st.size, BigInt(data.length)); - assertTrue(st.dataModificationTimestamp !== undefined, "mtime from File.lastModified"); + assertTrue( + st.dataModificationTimestamp !== undefined, + "mtime from File.lastModified", + ); await f.setSize(4n); assertEq((await f.stat()).size, 4n); // Committed to the fake's backing store, not a shadow copy. - const committed = (await (await fake.getFileHandle("hello.txt")).getFile()).size; + const committed = + (await (await fake.getFileHandle("hello.txt")).getFile()).size; assertEq(committed, 4); }); @@ -135,18 +154,34 @@ Deno.test("fs-web 0.2: async-backed via-streams (blocking ops park)", async () = if (r.length === 0) continue; } }); - assertEq(((closed as ComponentException).payload as { kind: string }).kind, "closed"); + assertEq( + ((closed as ComponentException).payload as { kind: string }).kind, + "closed", + ); }); Deno.test("fs-web: error shapes per track; unsupported families", async () => { const { root02, root03 } = setup(); - assertEq(await rejectedPayload(() => root02.statAt(FOLLOW, "missing")), "no-entry"); assertEq( - ((await rejectedPayload(() => root03.statAt(FOLLOW, "missing"))) as { kind: string }).kind, + await rejectedPayload(() => root02.statAt(FOLLOW, "missing")), "no-entry", ); - assertEq(await rejectedPayload(() => root02.setTimes({ kind: "now" }, { kind: "now" })), "unsupported"); - assertEq(await rejectedPayload(() => root02.symlinkAt("a", "b")), "unsupported"); + assertEq( + ((await rejectedPayload(() => root03.statAt(FOLLOW, "missing"))) as { + kind: string; + }).kind, + "no-entry", + ); + assertEq( + await rejectedPayload(() => + root02.setTimes({ kind: "now" }, { kind: "now" }) + ), + "unsupported", + ); + assertEq( + await rejectedPayload(() => root02.symlinkAt("a", "b")), + "unsupported", + ); }); Deno.test("fs-web 0.2: directories, exclusive create, rename fallback", async () => { @@ -156,21 +191,36 @@ Deno.test("fs-web 0.2: directories, exclusive create, rename fallback", async () const f = await root02.openAt(FOLLOW, "d/x.txt", { create: true }, RW); await f.write(new Uint8Array([7, 8, 9]), 0n); assertEq( - await rejectedPayload(() => root02.openAt(FOLLOW, "d/x.txt", { create: true, exclusive: true }, RW)), + await rejectedPayload(() => + root02.openAt(FOLLOW, "d/x.txt", { create: true, exclusive: true }, RW) + ), "exist", ); - assertEq(await rejectedPayload(() => root02.removeDirectoryAt("d")), "not-empty"); + assertEq( + await rejectedPayload(() => root02.removeDirectoryAt("d")), + "not-empty", + ); // No move() on the fake: file rename takes the copy+delete fallback... await root02.renameAt("d/x.txt", root02, "y.txt"); assertEq((await root02.statAt(FOLLOW, "y.txt")).size, 3n); - assertEq(await rejectedPayload(() => root02.statAt(FOLLOW, "d/x.txt")), "no-entry"); + assertEq( + await rejectedPayload(() => root02.statAt(FOLLOW, "d/x.txt")), + "no-entry", + ); // ...and directory rename is honestly unsupported. - assertEq(await rejectedPayload(() => root02.renameAt("d", root02, "e")), "unsupported"); + assertEq( + await rejectedPayload(() => root02.renameAt("d", root02, "e")), + "unsupported", + ); const listing = await root02.readDirectory(); const names: string[] = []; - for (let e = listing.readDirectoryEntry(); e !== undefined; e = listing.readDirectoryEntry()) { + for ( + let e = listing.readDirectoryEntry(); + e !== undefined; + e = listing.readDirectoryEntry() + ) { names.push(e.name); } assertEq(names.sort().join(","), "d,y.txt"); @@ -185,7 +235,10 @@ Deno.test("fs-web: identity is path-derived; is-same-object via isSameEntry", as assertEq(await a1.isSameObject(root02), false); const h1 = await a1.metadataHash(); const h2 = await a2.metadataHash(); - assertTrue(h1.lower === h2.lower && h1.upper === h2.upper, "same path, same hash"); + assertTrue( + h1.lower === h2.lower && h1.upper === h2.upper, + "same path, same hash", + ); }); Deno.test("fs-web 0.3: write-via-stream commits through the fake", async () => { diff --git a/wasi/tests/http_test.ts b/wasi/tests/http_test.ts index 96a3655..b042402 100644 --- a/wasi/tests/http_test.ts +++ b/wasi/tests/http_test.ts @@ -10,16 +10,21 @@ import { ComponentException } from "@polyengine/protocol"; import { - HTTP_TRACK, type ErrorCode, type Fields, http, + HTTP_TRACK, type HttpResult, type Request, type Response, type TrailersResult, } from "../src/http.ts"; -import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; +import { + assertEq, + assertRejects, + assertThrows, + assertTrue, +} from "./asserts.ts"; const { Fields, Request, RequestOptions, Response, send, imports } = http(); @@ -28,13 +33,19 @@ const utf8 = (b: Uint8Array): string => new TextDecoder().decode(b); function errKind(fn: () => unknown): string { const e = assertThrows(fn); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException<{ kind: string }>).payload.kind; } async function errKindAsync(p: Promise): Promise { const e = await assertRejects(() => p); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); return (e as ComponentException).payload.kind; } @@ -44,7 +55,10 @@ async function collect(stream: AsyncIterable): Promise { return Uint8Array.from(chunks); } -const okTrailers: Promise = Promise.resolve({ kind: "ok", value: undefined }); +const okTrailers: Promise = Promise.resolve({ + kind: "ok", + value: undefined, +}); const okRes: Promise = Promise.resolve({ kind: "ok" }); /** A request aimed at 127.0.0.1:port over plain http. */ @@ -74,7 +88,9 @@ function loopbackRequest( /** A loopback HTTP server for one test. */ function serve( - handler: (req: globalThis.Request) => globalThis.Response | Promise, + handler: ( + req: globalThis.Request, + ) => globalThis.Response | Promise, ): Promise<{ port: number; shutdown: () => Promise }> { return new Promise((resolve) => { const server = Deno.serve({ @@ -110,8 +126,14 @@ Deno.test("http fields: set/append/delete/get-and-delete; from-list validation", f.set("b", [text("x")]); f.delete("b"); assertEq(f.has("b"), false); - assertEq(errKind(() => Fields.fromList([["bad header", text("v")]])), "invalid-syntax"); - assertEq(errKind(() => f.set("ok", [text("bad\r\nvalue")])), "invalid-syntax"); + assertEq( + errKind(() => Fields.fromList([["bad header", text("v")]])), + "invalid-syntax", + ); + assertEq( + errKind(() => f.set("ok", [text("bad\r\nvalue")])), + "invalid-syntax", + ); }); Deno.test("http fields: immutability — request views refuse mutation, clone is mutable", () => { @@ -133,7 +155,12 @@ Deno.test("http fields: immutability — request views refuse mutation, clone is // --- request accessors ------------------------------------------------------------ Deno.test("http request: accessor defaults, round trips, and validation", () => { - const [request] = Request["new"](new Fields(), undefined, okTrailers, undefined); + const [request] = Request["new"]( + new Fields(), + undefined, + okTrailers, + undefined, + ); assertEq(request.getMethod().kind, "get"); assertEq(request.getPathWithQuery(), undefined); assertEq(request.getScheme(), undefined); @@ -146,7 +173,9 @@ Deno.test("http request: accessor defaults, round trips, and validation", () => assertEq(request.getScheme()?.kind, "HTTPS"); request.setAuthority("example.com:8443"); assertEq(request.getAuthority(), "example.com:8443"); - assertThrows(() => request.setMethod({ kind: "other", value: "not a token" })); + assertThrows(() => + request.setMethod({ kind: "other", value: "not a token" }) + ); assertThrows(() => request.setPathWithQuery("/sp ace")); assertThrows(() => request.setScheme({ kind: "other", value: "9bad" })); request[Symbol.dispose](); @@ -170,7 +199,9 @@ Deno.test("http send: GET round trip — status, headers, streamed body; transmi const server = await serve((req) => { assertEq(new URL(req.url).pathname, "/hello"); assertEq(req.headers.get("x-probe"), "42"); - return new globalThis.Response("hi there", { headers: { "x-answer": "97" } }); + return new globalThis.Response("hi there", { + headers: { "x-answer": "97" }, + }); }); try { const [request, transmitted] = loopbackRequest(server.port, "/hello", { @@ -190,7 +221,9 @@ Deno.test("http send: GET round trip — status, headers, streamed body; transmi }); Deno.test("http send: POST body is transmitted (buffered divergence), echo returns", async () => { - const server = await serve(async (req) => new globalThis.Response(await req.bytes())); + const server = await serve(async (req) => + new globalThis.Response(await req.bytes()) + ); try { const [request] = loopbackRequest(server.port, "/echo", { method: { kind: "post" }, @@ -211,7 +244,10 @@ Deno.test("http send: POST body is transmitted (buffered divergence), echo retur Deno.test("http send: redirects are NOT followed (manual, the wasmtime-parity stance)", async () => { const server = await serve((req) => new URL(req.url).pathname === "/from" - ? new globalThis.Response(null, { status: 302, headers: { location: "/to" } }) + ? new globalThis.Response(null, { + status: 302, + headers: { location: "/to" }, + }) : new globalThis.Response("followed?!") ); try { @@ -247,7 +283,10 @@ Deno.test("http send: request trailers cannot ride fetch — some(trailers) fail const server = await serve(() => new globalThis.Response("x")); try { const [request, transmitted] = loopbackRequest(server.port, "/", { - trailers: Promise.resolve({ kind: "ok", value: new Fields() }), + trailers: Promise.resolve({ + kind: "ok", + value: new Fields(), + }), }); assertEq(await errKindAsync(send(request)), "internal-error"); assertEq((await transmitted).kind, "err"); @@ -292,7 +331,11 @@ Deno.test("http timeouts: first-byte timeout errs the body future, not the send" request.setPathWithQuery("/"); const response = await send(request); // headers made it: send succeeds const [body, done] = Response.consumeBody(response, okRes); - assertEq((await collect(body)).length, 0, "the stream ends without fake data"); + assertEq( + (await collect(body)).length, + 0, + "the stream ends without fake data", + ); const t = await done; assertEq(t.kind, "err"); assertEq( @@ -325,11 +368,20 @@ Deno.test("http consume-body: a constructed request's body and trailers pass thr assertEq(t.kind, "ok"); assertTrue(t.kind === "ok" && t.value !== undefined, "trailers arrive"); settleRes({ kind: "ok" }); - assertEq((await transmitted).kind, "ok", "res settles the transmission future"); + assertEq( + (await transmitted).kind, + "ok", + "res settles the transmission future", + ); }); Deno.test("http dispose: an unsent request settles its transmission future as err", async () => { - const [request, transmitted] = Request["new"](new Fields(), undefined, okTrailers, undefined); + const [request, transmitted] = Request["new"]( + new Fields(), + undefined, + okTrailers, + undefined, + ); request[Symbol.dispose](); const t = await transmitted; assertEq(t.kind, "err"); @@ -364,9 +416,17 @@ Deno.test("fragment: an embedder that means handler-over-fetch registers `send` // CONTRACT: wasi/src/http.ts:16-20 — handler is deliberately not // registered by this fragment; the supported recipe is the embedder // merging `send` into its own handler-keyed provider (issue #179). - const embedderMerged = { ...imports, [`wasi:http/handler@0.3`]: { handle: send } }; - const handler = embedderMerged["wasi:http/handler@0.3"] as { handle: unknown }; - assertTrue(handler.handle === send, "handle IS client.send when the embedder opts in"); + const embedderMerged = { + ...imports, + [`wasi:http/handler@0.3`]: { handle: send }, + }; + const handler = embedderMerged["wasi:http/handler@0.3"] as { + handle: unknown; + }; + assertTrue( + handler.handle === send, + "handle IS client.send when the embedder opts in", + ); }); // --- allowRequest: name-level egress policy --------------------------------------- @@ -398,16 +458,19 @@ function stubFetch(impl: typeof fetch): { calls: number; restore: () => void } { * call count and the last `Request` seen. */ function fakeTransport( - impl: (request: globalThis.Request) => globalThis.Response | Promise, + impl: ( + request: globalThis.Request, + ) => globalThis.Response | Promise, ): { calls: number; lastRequest: globalThis.Request | undefined; fn: (request: globalThis.Request) => Promise; } { - const state: { calls: number; lastRequest: globalThis.Request | undefined } = { - calls: 0, - lastRequest: undefined, - }; + const state: { calls: number; lastRequest: globalThis.Request | undefined } = + { + calls: 0, + lastRequest: undefined, + }; return { get calls() { return state.calls; @@ -431,7 +494,11 @@ function fakeTransport( function requestWithObservedBody( port: number, headers: [string, Uint8Array][] = [], -): { request: Request; transmitted: Promise; consumed: () => boolean } { +): { + request: Request; + transmitted: Promise; + consumed: () => boolean; +} { let consumed = false; const contents = (async function* () { consumed = true; @@ -460,7 +527,12 @@ Deno.test("http allowRequest: default (no options) still dispatches — fetch re const stub = stubFetch(() => Promise.resolve(new globalThis.Response("ok"))); try { const { Request: R, Fields: F, send: s } = http(); - const [request] = R["new"](F.fromList([]), undefined, okTrailers, undefined); + const [request] = R["new"]( + F.fromList([]), + undefined, + okTrailers, + undefined, + ); request.setScheme({ kind: "HTTP" }); request.setAuthority("127.0.0.1:9"); await s(request); @@ -472,7 +544,10 @@ Deno.test("http allowRequest: default (no options) still dispatches — fetch re Deno.test("http allowRequest: explicit true dispatches — transport reached", async () => { const transport = fakeTransport(() => new globalThis.Response("ok")); - const { Request: R, Fields: F, send: s } = http({ allowRequest: true, fetch: transport.fn }); + const { Request: R, Fields: F, send: s } = http({ + allowRequest: true, + fetch: transport.fn, + }); const [request] = R["new"](F.fromList([]), undefined, okTrailers, undefined); request.setScheme({ kind: "HTTP" }); request.setAuthority("127.0.0.1:9"); @@ -486,7 +561,11 @@ Deno.test("http allowRequest: false denies with HTTP-request-denied; transport n const { request, transmitted } = requestWithObservedBody(9); assertEq(await errKindAsync(s(request)), "HTTP-request-denied"); assertEq((await transmitted).kind, "err"); - assertEq(transport.calls, 0, "transport never dispatched when allowRequest is false"); + assertEq( + transport.calls, + 0, + "transport never dispatched when allowRequest is false", + ); }); Deno.test("http allowRequest: false does not consume the body stream", async () => { @@ -504,7 +583,8 @@ Deno.test("http allowRequest: false does not consume the body stream", async () Deno.test("http allowRequest: types/client keys still registered when false", () => { const { imports } = http({ allowRequest: false }); assertTrue( - `wasi:http/types@${HTTP_TRACK}` in imports && `wasi:http/client@${HTTP_TRACK}` in imports, + `wasi:http/types@${HTTP_TRACK}` in imports && + `wasi:http/client@${HTTP_TRACK}` in imports, "types + client keys present even with egress denied", ); }); @@ -519,7 +599,12 @@ Deno.test("http allowRequest: callback observes url/method/headers", async () => }, fetch: transport.fn, }); - const [request] = R["new"](F.fromList([["x-probe", text("42")]]), undefined, okTrailers, undefined); + const [request] = R["new"]( + F.fromList([["x-probe", text("42")]]), + undefined, + okTrailers, + undefined, + ); request.setMethod({ kind: "post" }); request.setScheme({ kind: "HTTPS" }); request.setAuthority("example.com"); @@ -535,8 +620,16 @@ Deno.test("http allowRequest: callback observes url/method/headers", async () => Deno.test("http allowRequest: callback returning true dispatches, false denies", async () => { const allowedTransport = fakeTransport(() => new globalThis.Response("ok")); - const allowed = http({ allowRequest: () => true, fetch: allowedTransport.fn }); - const [ra] = allowed.Request["new"](allowed.Fields.fromList([]), undefined, okTrailers, undefined); + const allowed = http({ + allowRequest: () => true, + fetch: allowedTransport.fn, + }); + const [ra] = allowed.Request["new"]( + allowed.Fields.fromList([]), + undefined, + okTrailers, + undefined, + ); ra.setScheme({ kind: "HTTP" }); ra.setAuthority("127.0.0.1:9"); await allowed.send(ra); @@ -544,29 +637,58 @@ Deno.test("http allowRequest: callback returning true dispatches, false denies", const deniedTransport = fakeTransport(() => new globalThis.Response("ok")); const denied = http({ allowRequest: () => false, fetch: deniedTransport.fn }); - const [rd] = denied.Request["new"](denied.Fields.fromList([]), undefined, okTrailers, undefined); + const [rd] = denied.Request["new"]( + denied.Fields.fromList([]), + undefined, + okTrailers, + undefined, + ); rd.setScheme({ kind: "HTTP" }); rd.setAuthority("127.0.0.1:9"); assertEq(await errKindAsync(denied.send(rd)), "HTTP-request-denied"); - assertEq(deniedTransport.calls, 0, "false denies without dispatching the transport"); + assertEq( + deniedTransport.calls, + 0, + "false denies without dispatching the transport", + ); }); Deno.test("http allowRequest: async callback (resolves true/false) both directions work", async () => { const allowedTransport = fakeTransport(() => new globalThis.Response("ok")); - const allowed = http({ allowRequest: () => Promise.resolve(true), fetch: allowedTransport.fn }); - const [ra] = allowed.Request["new"](allowed.Fields.fromList([]), undefined, okTrailers, undefined); + const allowed = http({ + allowRequest: () => Promise.resolve(true), + fetch: allowedTransport.fn, + }); + const [ra] = allowed.Request["new"]( + allowed.Fields.fromList([]), + undefined, + okTrailers, + undefined, + ); ra.setScheme({ kind: "HTTP" }); ra.setAuthority("127.0.0.1:9"); await allowed.send(ra); assertEq(allowedTransport.calls, 1, "async true dispatches"); const deniedTransport = fakeTransport(() => new globalThis.Response("ok")); - const denied = http({ allowRequest: () => Promise.resolve(false), fetch: deniedTransport.fn }); - const [rd] = denied.Request["new"](denied.Fields.fromList([]), undefined, okTrailers, undefined); + const denied = http({ + allowRequest: () => Promise.resolve(false), + fetch: deniedTransport.fn, + }); + const [rd] = denied.Request["new"]( + denied.Fields.fromList([]), + undefined, + okTrailers, + undefined, + ); rd.setScheme({ kind: "HTTP" }); rd.setAuthority("127.0.0.1:9"); assertEq(await errKindAsync(denied.send(rd)), "HTTP-request-denied"); - assertEq(deniedTransport.calls, 0, "async false denies without dispatching the transport"); + assertEq( + deniedTransport.calls, + 0, + "async false denies without dispatching the transport", + ); }); Deno.test("http allowRequest: a throwing callback denies (fail closed); detail names the thrown message", async () => { @@ -579,13 +701,25 @@ Deno.test("http allowRequest: a throwing callback denies (fail closed); detail n }); const { request } = requestWithObservedBody(9); const e = await assertRejects(() => s(request)); - assertTrue(e instanceof ComponentException, "throws a branded ComponentException"); - assertEq((e as ComponentException).payload.kind, "HTTP-request-denied"); assertTrue( - String((e as ComponentException).message).includes("policy blew up"), + e instanceof ComponentException, + "throws a branded ComponentException", + ); + assertEq( + (e as ComponentException).payload.kind, + "HTTP-request-denied", + ); + assertTrue( + String((e as ComponentException).message).includes( + "policy blew up", + ), "the ComponentException detail mentions the thrown message", ); - assertEq(transport.calls, 0, "transport never dispatched when the callback throws"); + assertEq( + transport.calls, + 0, + "transport never dispatched when the callback throws", + ); }); Deno.test("http allowRequest: a rejecting async callback denies (fail closed); detail names the rejection", async () => { @@ -596,13 +730,25 @@ Deno.test("http allowRequest: a rejecting async callback denies (fail closed); d }); const { request } = requestWithObservedBody(9); const e = await assertRejects(() => s(request)); - assertTrue(e instanceof ComponentException, "throws a branded ComponentException"); - assertEq((e as ComponentException).payload.kind, "HTTP-request-denied"); assertTrue( - String((e as ComponentException).message).includes("async policy blew up"), + e instanceof ComponentException, + "throws a branded ComponentException", + ); + assertEq( + (e as ComponentException).payload.kind, + "HTTP-request-denied", + ); + assertTrue( + String((e as ComponentException).message).includes( + "async policy blew up", + ), "the ComponentException detail mentions the rejection message", ); - assertEq(transport.calls, 0, "transport never dispatched when the async callback rejects"); + assertEq( + transport.calls, + 0, + "transport never dispatched when the async callback rejects", + ); }); // --- fetch: injectable transport --------------------------------------------------- @@ -637,7 +783,9 @@ Deno.test("http fetch option: request.clone() peeks the body without stealing it // Forward the ORIGINAL (not the clone) — proves clone() didn't consume it. return new globalThis.Response(await req.arrayBuffer()); }); - const { Request: R, Fields: F, Response: Resp, send: s } = http({ fetch: transport.fn }); + const { Request: R, Fields: F, Response: Resp, send: s } = http({ + fetch: transport.fn, + }); const [request] = R["new"]( F.fromList([]), (async function* () { @@ -652,14 +800,23 @@ Deno.test("http fetch option: request.clone() peeks the body without stealing it const response = await s(request); assertEq(peeked, "peek me"); const [body] = Resp.consumeBody(response, okRes); - assertEq(utf8(await collect(body)), "peek me", "the forwarded request still delivers the body"); + assertEq( + utf8(await collect(body)), + "peek me", + "the forwarded request still delivers the body", + ); }); Deno.test("http fetch option: a synthesized Response (no network) reaches the guest", async () => { const transport = fakeTransport(() => - new globalThis.Response("synthetic body", { status: 201, headers: { "x-synth": "yes" } }) + new globalThis.Response("synthetic body", { + status: 201, + headers: { "x-synth": "yes" }, + }) ); - const { Request: R, Fields: F, Response: Resp, send: s } = http({ fetch: transport.fn }); + const { Request: R, Fields: F, Response: Resp, send: s } = http({ + fetch: transport.fn, + }); const [request] = R["new"](F.fromList([]), undefined, okTrailers, undefined); request.setScheme({ kind: "HTTP" }); request.setAuthority("example.com"); @@ -672,7 +829,10 @@ Deno.test("http fetch option: a synthesized Response (no network) reaches the gu Deno.test("http fetch option: a transport throwing a branded ComponentException surfaces that exact payload kind", async () => { const transport = fakeTransport(() => { - throw new ComponentException({ kind: "TLS-alert-received" }, "wasi:http: transport TLS alert"); + throw new ComponentException( + { kind: "TLS-alert-received" }, + "wasi:http: transport TLS alert", + ); }); const { send: s } = http({ fetch: transport.fn }); const { request, transmitted } = requestWithObservedBody(9); diff --git a/wasi/tests/integration_engine_go_test.ts b/wasi/tests/integration_engine_go_test.ts index d56d915..721d198 100644 --- a/wasi/tests/integration_engine_go_test.ts +++ b/wasi/tests/integration_engine_go_test.ts @@ -20,7 +20,8 @@ import { Translator } from "@polyengine/runtime/shim"; import { instantiate } from "@polyengine/runtime/embedder"; import { wasi } from "../src/mod.ts"; -const ARTIFACT = "/home/lmartin/p/polymorph/experiment-mosh/engine-go/main.wasm"; +const ARTIFACT = + "/home/lmartin/p/polymorph/experiment-mosh/engine-go/main.wasm"; const SHIM_WASM = new URL( "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", import.meta.url, @@ -94,7 +95,9 @@ Deno.test({ const version = await engine.version(); assertTrue( typeof version === "string" && version.length > 0, - `version() should return a non-empty string, got: ${JSON.stringify(version)}`, + `version() should return a non-empty string, got: ${ + JSON.stringify(version) + }`, ); assertTrue( version.includes("engine"), diff --git a/wasi/tests/integration_exec_model_test.ts b/wasi/tests/integration_exec_model_test.ts index faddea1..e42fb13 100644 --- a/wasi/tests/integration_exec_model_test.ts +++ b/wasi/tests/integration_exec_model_test.ts @@ -10,7 +10,7 @@ import { assertEq, assertTrue } from "./asserts.ts"; import { Translator } from "@polyengine/runtime/shim"; import { instantiate } from "@polyengine/runtime/embedder"; -import { Stream } from "@polyengine/protocol"; +import type { Stream } from "@polyengine/protocol"; import { wasi } from "../src/mod.ts"; const ARTIFACT = diff --git a/wasi/tests/integration_fs_test.ts b/wasi/tests/integration_fs_test.ts index c85363e..34b6a86 100644 --- a/wasi/tests/integration_fs_test.ts +++ b/wasi/tests/integration_fs_test.ts @@ -73,7 +73,8 @@ Deno.test({ }); Deno.test({ - name: "integration: a guest error path — err payloads survive the adapter round-trip", + name: + "integration: a guest error path — err payloads survive the adapter round-trip", ignore: !ready, async fn() { // No preopen named "/": wasi-libc can't resolve "/work", and the @@ -93,8 +94,12 @@ Deno.test({ } assertTrue(threw !== undefined, "the guest reported a failure"); assertTrue( - String((threw as { payload?: unknown })?.payload ?? threw).includes("create_dir"), - `the first failing step is named, got: ${(threw as { payload?: unknown })?.payload}`, + String((threw as { payload?: unknown })?.payload ?? threw).includes( + "create_dir", + ), + `the first failing step is named, got: ${ + (threw as { payload?: unknown })?.payload + }`, ); }, }); diff --git a/wasi/tests/integration_http_test.ts b/wasi/tests/integration_http_test.ts index f280d0d..9f796c7 100644 --- a/wasi/tests/integration_http_test.ts +++ b/wasi/tests/integration_http_test.ts @@ -38,19 +38,22 @@ const componentBytes = await readIfPresent(FIXTURE); const shimWasm = await readIfPresent(SHIM_WASM); const ready = componentBytes !== null && shimWasm !== null; -// deno-lint-ignore no-explicit-any async function instantiateFixture( calls?: string[], httpOptions?: Parameters[0], + // deno-lint-ignore no-explicit-any ): Promise { const translator = await Translator.create(shimWasm!); const { plan, adapters } = translator.translate(componentBytes!); - return await instantiate({ plan, componentBytes: componentBytes!, adapters }, { - ...http({ - ...(httpOptions ?? {}), - ...(calls === undefined ? {} : { onCall: (c) => calls.push(c) }), - }).imports, - }); + return await instantiate( + { plan, componentBytes: componentBytes!, adapters }, + { + ...http({ + ...(httpOptions ?? {}), + ...(calls === undefined ? {} : { onCall: (c) => calls.push(c) }), + }).imports, + }, + ); } function serve( @@ -61,14 +64,18 @@ function serve( hostname: "127.0.0.1", port: 0, onListen({ port }) { - resolve({ authority: `127.0.0.1:${port}`, shutdown: () => server.shutdown() }); + resolve({ + authority: `127.0.0.1:${port}`, + shutdown: () => server.shutdown(), + }); }, }, handler); }); } Deno.test({ - name: "integration: guest GET through fetch — status, streamed body, driving sequence", + name: + "integration: guest GET through fetch — status, streamed body, driving sequence", ignore: !ready, async fn() { const server = await serve((req) => { @@ -78,7 +85,10 @@ Deno.test({ try { const calls: string[] = []; const c = await instantiateFixture(calls); - const [status, body] = await c.exports.get(server.authority, "/hello") as [ + const [status, body] = await c.exports.get( + server.authority, + "/hello", + ) as [ number, Uint8Array, ]; @@ -86,7 +96,10 @@ Deno.test({ assertEq(new TextDecoder().decode(body), "hello from the loopback"); assertTrue(calls.includes("request.new"), "constructor dispatched"); assertTrue(calls.includes("client.send"), "send dispatched"); - assertTrue(calls.includes("response.consume-body"), "consume-body dispatched"); + assertTrue( + calls.includes("response.consume-body"), + "consume-body dispatched", + ); } finally { await server.shutdown(); } @@ -94,14 +107,19 @@ Deno.test({ }); Deno.test({ - name: "integration: guest POST — a guest-written body stream crosses fetch and echoes back", + name: + "integration: guest POST — a guest-written body stream crosses fetch and echoes back", ignore: !ready, async fn() { const server = await serve(async (req) => new Response(await req.bytes())); try { const c = await instantiateFixture(); const payload = Uint8Array.from({ length: 4096 }, (_, i) => i % 251); - const echoed = await c.exports.postEcho(server.authority, "/echo", payload) as Uint8Array; + const echoed = await c.exports.postEcho( + server.authority, + "/echo", + payload, + ) as Uint8Array; assertEq(echoed.length, payload.length); assertTrue( echoed.every((b, i) => b === payload[i]), @@ -114,7 +132,8 @@ Deno.test({ }); Deno.test({ - name: "integration: a refused dial reaches the guest as the error-code's err case", + name: + "integration: a refused dial reaches the guest as the error-code's err case", ignore: !ready, async fn() { const probe = await serve(() => new Response("x")); @@ -130,14 +149,19 @@ Deno.test({ // result err — which the conventions surface as a ComponentException. assertTrue(threw !== undefined, "the guest observed the error"); assertTrue( - String((threw as { payload?: unknown })?.payload ?? threw).includes("ConnectionRefused"), - `the error names the refusal, got: ${(threw as { payload?: unknown })?.payload}`, + String((threw as { payload?: unknown })?.payload ?? threw).includes( + "ConnectionRefused", + ), + `the error names the refusal, got: ${ + (threw as { payload?: unknown })?.payload + }`, ); }, }); Deno.test({ - name: "integration: allowRequest: false denies the guest's request before it reaches fetch", + name: + "integration: allowRequest: false denies the guest's request before it reaches fetch", ignore: !ready, async fn() { // No server needed: denial happens before dispatch. A live server diff --git a/wasi/tests/integration_net_test.ts b/wasi/tests/integration_net_test.ts index bde9478..5035b71 100644 --- a/wasi/tests/integration_net_test.ts +++ b/wasi/tests/integration_net_test.ts @@ -45,7 +45,8 @@ const shimWasm = await readIfPresent(SHIM_WASM); const ready = componentBytes !== null && shimWasm !== null; Deno.test({ - name: "integration: std::net battery over the 0.2 sockets track (self-echo + udp pair)", + name: + "integration: std::net battery over the 0.2 sockets track (self-echo + udp pair)", ignore: !ready, async fn() { const calls: string[] = []; @@ -61,23 +62,25 @@ Deno.test({ const summary = await c.exports.run() as string; assertEq(summary, "net probe ok"); // The driving sequence proves the poll-shaped path was exercised. - for (const expected of [ - "tcp-create-socket.create-tcp-socket", - "tcp-socket.start-bind", - "tcp-socket.finish-bind", - "tcp-socket.start-listen", - "tcp-socket.finish-listen", - "tcp-socket.start-connect", - "tcp-socket.finish-connect", - "tcp-socket.accept", - "tcp-socket.shutdown", - "udp-create-socket.create-udp-socket", - "udp-socket.start-bind", - "udp-socket.stream", - "incoming-datagram-stream.receive", - "outgoing-datagram-stream.check-send", - "outgoing-datagram-stream.send", - ]) { + for ( + const expected of [ + "tcp-create-socket.create-tcp-socket", + "tcp-socket.start-bind", + "tcp-socket.finish-bind", + "tcp-socket.start-listen", + "tcp-socket.finish-listen", + "tcp-socket.start-connect", + "tcp-socket.finish-connect", + "tcp-socket.accept", + "tcp-socket.shutdown", + "udp-create-socket.create-udp-socket", + "udp-socket.start-bind", + "udp-socket.stream", + "incoming-datagram-stream.receive", + "outgoing-datagram-stream.check-send", + "outgoing-datagram-stream.send", + ] + ) { assertTrue(calls.includes(expected), `${expected} dispatched`); } }, diff --git a/wasi/tests/integration_sockets_test.ts b/wasi/tests/integration_sockets_test.ts index caa6c4f..0fe15d8 100644 --- a/wasi/tests/integration_sockets_test.ts +++ b/wasi/tests/integration_sockets_test.ts @@ -55,10 +55,13 @@ const ready = componentBytes !== null && shimWasm !== null; async function instantiateFixture(calls?: string[]): Promise { const translator = await Translator.create(shimWasm!); const { plan, adapters } = translator.translate(componentBytes!); - return await instantiate({ plan, componentBytes: componentBytes!, adapters }, { - ...sockets(calls === undefined ? {} : { onCall: (c) => calls.push(c) }) - .imports, - }); + return await instantiate( + { plan, componentBytes: componentBytes!, adapters }, + { + ...sockets(calls === undefined ? {} : { onCall: (c) => calls.push(c) }) + .imports, + }, + ); } /** Write all of `bytes` to a raw conn. */ @@ -82,7 +85,11 @@ Deno.test({ name: "integration: guest tcp client echoes through a live loopback server", ignore: !ready, async fn() { - const listener = Deno.listen({ transport: "tcp", hostname: "127.0.0.1", port: 0 }); + const listener = Deno.listen({ + transport: "tcp", + hostname: "127.0.0.1", + port: 0, + }); const { port } = listener.addr as Deno.NetAddr; const serverDone = (async () => { const conn = await listener.accept(); @@ -144,7 +151,11 @@ Deno.test({ // in flight while these echo (settlement pump + stream activity). const payloads = [[1, 2, 3], [4, 5, 6, 7]]; for (const p of payloads) { - const conn = await Deno.connect({ transport: "tcp", hostname: "127.0.0.1", port }); + const conn = await Deno.connect({ + transport: "tcp", + hostname: "127.0.0.1", + port, + }); await writeAll(conn, Uint8Array.from(p)); await conn.closeWrite(); // FIN: the guest reads to end, echoes, FINs assertEq(JSON.stringify(await readToEnd(conn)), JSON.stringify(p)); @@ -160,11 +171,18 @@ Deno.test({ // refuses a fresh dial. let refused = false; try { - const conn = await Deno.connect({ transport: "tcp", hostname: "127.0.0.1", port }); + const conn = await Deno.connect({ + transport: "tcp", + hostname: "127.0.0.1", + port, + }); conn.close(); } catch (e) { refused = e instanceof Deno.errors.ConnectionRefused; } - assertTrue(refused, "the listener's port refuses new dials after the guest quit"); + assertTrue( + refused, + "the listener's port refuses new dials after the guest quit", + ); }, }); diff --git a/wasi/tests/io_test.ts b/wasi/tests/io_test.ts index eb2c828..0beb0e0 100644 --- a/wasi/tests/io_test.ts +++ b/wasi/tests/io_test.ts @@ -3,7 +3,7 @@ import { assertEq, assertRejects, assertTrue } from "./asserts.ts"; import { ComponentException } from "@polyengine/protocol"; -import { InputStream, io, OutputStream, Pollable, poll } from "../src/io.ts"; +import { InputStream, io, OutputStream, poll, Pollable } from "../src/io.ts"; import type { StreamErrorValue } from "../src/io.ts"; Deno.test("io: pollable is always ready (tier a) and block() is a no-op", () => { @@ -44,7 +44,10 @@ Deno.test("io: writes after drop throw ComponentException 'closed' out.write(new Uint8Array([1])); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof ComponentException, "closed write throws ComponentException"); + assertTrue( + e instanceof ComponentException, + "closed write throws ComponentException", + ); const payload = (e as ComponentException).payload; assertEq(payload.kind, "closed"); } @@ -58,7 +61,10 @@ Deno.test("io: checkWrite after drop also throws the closed stream-error", () => throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -76,8 +82,14 @@ Deno.test("io: InputStream.read reaches closed after the buffer drains (issue #1 s.read(2n); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof ComponentException, "drained read throws ComponentException"); - assertEq((e as ComponentException).payload.kind, "closed"); + assertTrue( + e instanceof ComponentException, + "drained read throws ComponentException", + ); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } // Once closed at EOF, subsequent reads keep throwing closed (the loop // terminates, it doesn't oscillate). @@ -85,7 +97,10 @@ Deno.test("io: InputStream.read reaches closed after the buffer drains (issue #1 s.read(1n); throw new Error("expected a throw"); } catch (e) { - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -105,7 +120,10 @@ Deno.test("io: InputStream defaults to an empty buffer that is closed on first n throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -117,13 +135,19 @@ Deno.test("io: reading a dropped input stream throws closed stream-error", () => throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); Deno.test("io: blockingRead degenerates to read (tier b, never parks)", () => { const s = new InputStream(new Uint8Array([7, 8])); - assertEq(JSON.stringify([...(s.blockingRead(2n) as Uint8Array)]), JSON.stringify([7, 8])); + assertEq( + JSON.stringify([...(s.blockingRead(2n) as Uint8Array)]), + JSON.stringify([7, 8]), + ); }); // Issue #178: blockingRead inherits read's closed-at-EOF signal, so a @@ -136,7 +160,10 @@ Deno.test("io: blockingRead reaches closed after the buffer drains (issue #178 l throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -151,7 +178,10 @@ Deno.test("io: skip reaches closed after the buffer drains (issue #178 livelock) throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -163,7 +193,10 @@ Deno.test("io: blockingSkip reaches closed after the buffer drains (issue #178 l throw new Error("expected a throw"); } catch (e) { assertTrue(e instanceof ComponentException); - assertEq((e as ComponentException).payload.kind, "closed"); + assertEq( + (e as ComponentException).payload.kind, + "closed", + ); } }); @@ -189,10 +222,12 @@ Deno.test("io: a closed-stream failure never leaks an unbranded throw type", asy const out = new OutputStream(() => { throw new Error("sink exploded"); }); - const rejected = await assertRejects(async () => { + const rejected = await assertRejects(() => { out.write(new Uint8Array([1])); }); - assertTrue(rejected instanceof Error && !(rejected instanceof ComponentException)); + assertTrue( + rejected instanceof Error && !(rejected instanceof ComponentException), + ); // NOTE: this documents current behavior — a sink that itself throws // propagates its raw Error out of this synchronous host-import function. // Per contracts/embedder-api.md §"Error model", the *embedder facade* diff --git a/wasi/tests/node_smoke.ts b/wasi/tests/node_smoke.ts index 45adf24..d364c4d 100644 --- a/wasi/tests/node_smoke.ts +++ b/wasi/tests/node_smoke.ts @@ -8,7 +8,11 @@ // resolves the workspace imports into one self-contained ESM file (the // recipe body in the justfile). -import { type IpSocketAddress, type SocketResult, sockets } from "../src/sockets.ts"; +import { + type IpSocketAddress, + type SocketResult, + sockets, +} from "../src/sockets.ts"; import { http, type TrailersResult } from "../src/http.ts"; import { filesystemNode } from "../src/filesystem_node.ts"; @@ -31,11 +35,17 @@ function errKindOf(e: unknown): string { } function resultErrKind(r: SocketResult, what: string): string { - assert(r.kind === "err", `${what}: expected err result, got ${JSON.stringify(r)}`); + assert( + r.kind === "err", + `${what}: expected err result, got ${JSON.stringify(r)}`, + ); return r.kind === "err" ? r.value.kind : ""; } -const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ +const v4 = ( + address: [number, number, number, number], + port: number, +): IpSocketAddress => ({ kind: "ipv4", value: { port, address }, }); @@ -44,9 +54,13 @@ async function* chunksOf(...chunks: number[][]): AsyncGenerator { for (const c of chunks) yield Uint8Array.from(c); } -async function collect(stream: AsyncIterable | Iterable): Promise { +async function collect( + stream: AsyncIterable | Iterable, +): Promise { const out: number[] = []; - for await (const chunk of stream as AsyncIterable) out.push(...chunk); + for await (const chunk of stream as AsyncIterable) { + out.push(...chunk); + } return out; } @@ -71,7 +85,9 @@ const nodeNet = (globalThis as unknown as { ): NodeTestServer; }; -function echoServer(): Promise<{ addr: IpSocketAddress; close: () => Promise }> { +function echoServer(): Promise< + { addr: IpSocketAddress; close: () => Promise } +> { const server = nodeNet.createServer({ allowHalfOpen: true }, (conn) => { conn.on("data", (...args) => conn.write(args[0] as Uint8Array)); conn.on("end", () => conn.end()); @@ -99,7 +115,10 @@ async function main(): Promise { const socket = UdpSocket.create("ipv4"); socket.bind(v4([127, 0, 0, 1], 0)); const addr = socket.getLocalAddress(); // same tick as bind — the sync-lookup trick - assert(addr.kind === "ipv4" && addr.value.port !== 0, "udp sync bind + get-local-address"); + assert( + addr.kind === "ipv4" && addr.value.port !== 0, + "udp sync bind + get-local-address", + ); socket[Symbol.dispose](); } @@ -131,7 +150,11 @@ async function main(): Promise { await socket.send(new Uint8Array(size), addr); assert(false, `udp oversize send (${size}) must fail`); } catch (e) { - assertEq(errKindOf(e), "datagram-too-large", `udp oversize send (${size})`); + assertEq( + errKindOf(e), + "datagram-too-large", + `udp oversize send (${size})`, + ); } } const other = UdpSocket.create("ipv4"); @@ -148,7 +171,11 @@ async function main(): Promise { await parked; assert(false, "udp parked receive must settle as err on dispose"); } catch (e) { - assertEq(errKindOf(e), "invalid-state", "udp dispose retires a parked receive"); + assertEq( + errKindOf(e), + "invalid-state", + "udp dispose retires a parked receive", + ); } } @@ -193,7 +220,10 @@ async function main(): Promise { assertEq(errKindOf(e), "connection-refused", "tcp refused dial"); } assertEq( - resultErrKind(await socket.send(chunksOf([1])), "tcp send after failed dial"), + resultErrKind( + await socket.send(chunksOf([1])), + "tcp send after failed dial", + ), "invalid-state", "tcp send after failed dial", ); @@ -202,9 +232,16 @@ async function main(): Promise { // --- tcp: peer-closed write settles the send future as err ------------------- { - const closer = nodeNet.createServer({ allowHalfOpen: false }, (conn) => conn.destroy()); + const closer = nodeNet.createServer( + { allowHalfOpen: false }, + (conn) => conn.destroy(), + ); const addr = await new Promise((resolve) => { - closer.listen(0, "127.0.0.1", () => resolve(v4([127, 0, 0, 1], closer.address().port))); + closer.listen( + 0, + "127.0.0.1", + () => resolve(v4([127, 0, 0, 1], closer.address().port)), + ); }); const socket = TcpSocket.create("ipv4"); await socket.connect(addr); @@ -216,7 +253,8 @@ async function main(): Promise { })()); const kind = resultErrKind(result, "tcp peer-closed write"); assert( - kind === "connection-reset" || kind === "connection-broken" || kind === "invalid-state", + kind === "connection-reset" || kind === "connection-broken" || + kind === "invalid-state", `tcp peer-closed write: a connection-failure kind, got ${kind}`, ); socket[Symbol.dispose](); @@ -262,12 +300,20 @@ async function main(): Promise { ); const { Fields, Request, Response, send } = http(); - const okTrailers = Promise.resolve({ kind: "ok", value: undefined }); + const okTrailers = Promise.resolve({ + kind: "ok", + value: undefined, + }); const okRes = Promise.resolve<{ kind: "ok" }>({ kind: "ok" }); // GET { - const [request] = Request["new"](new Fields(), undefined, okTrailers, undefined); + const [request] = Request["new"]( + new Fields(), + undefined, + okTrailers, + undefined, + ); request.setScheme({ kind: "HTTP" }); request.setAuthority(`127.0.0.1:${port}`); request.setPathWithQuery("/hello"); @@ -275,11 +321,17 @@ async function main(): Promise { assertEq(response.getStatusCode(), 203, "http GET status"); const [body] = Response.consumeBody(response, okRes); assertEq( - new TextDecoder().decode(await (async () => { - const out: number[] = []; - for await (const c of body as AsyncIterable) out.push(...c); - return Uint8Array.from(out); - })()), + new TextDecoder().decode( + await (async () => { + const out: number[] = []; + for await (const c of body as AsyncIterable) { + out.push( + ...c, + ); + } + return Uint8Array.from(out); + })(), + ), "hello from node", "http GET body", ); @@ -322,20 +374,32 @@ async function main(): Promise { }).process.getBuiltinModule("node:os") as { tmpdir(): string }; const dir = nodeFs.mkdtempSync(`${nodeOs.tmpdir()}/polyengine-fs-smoke-`); try { - const { imports } = filesystemNode({ preopens: { "/": dir }, writable: true }); + const { imports } = filesystemNode({ + preopens: { "/": dir }, + writable: true, + }); const [[root]] = (imports["wasi:filesystem/preopens@0.2"] as { // deno-lint-ignore no-explicit-any getDirectories(): [any, string][]; }).getDirectories(); // Sync-ness is the load-bearing claim on real Node: plain values. - const f = root.openAt({ symlinkFollow: true }, "smoke.txt", { create: true }, { + const f = root.openAt({ symlinkFollow: true }, "smoke.txt", { + create: true, + }, { read: true, write: true, }); - assert(!(f instanceof Promise), "fs open-at returns a plain value on node"); + assert( + !(f instanceof Promise), + "fs open-at returns a plain value on node", + ); assertEq(f.getType(), "regular-file", "fs get-type"); - assertEq(Number(f.write(Uint8Array.from([104, 105]), 0n)), 2, "fs positional write"); + assertEq( + Number(f.write(Uint8Array.from([104, 105]), 0n)), + 2, + "fs positional write", + ); const [bytes, eof] = f.read(8n, 0n); assertEq([...bytes], [104, 105], "fs positional read"); assertEq(eof, false, "fs read eof flag"); @@ -344,7 +408,11 @@ async function main(): Promise { out.write(Uint8Array.from([33])); out.blockingFlush(); const src = f.readViaStream(0n); - assertEq([...src.blockingRead(16n)], [104, 105, 33], "fs via-stream round-trip"); + assertEq( + [...src.blockingRead(16n)], + [104, 105, 33], + "fs via-stream round-trip", + ); const listing = root.readDirectory(); assertEq(listing.readDirectoryEntry()?.name, "smoke.txt", "fs listing"); @@ -366,7 +434,8 @@ async function main(): Promise { } } - const version = (globalThis as unknown as { process: { version: string } }).process.version; + const version = + (globalThis as unknown as { process: { version: string } }).process.version; // --- tcp listen: deferred bind, accept, echo, cancellation ------------------- { const socket = TcpSocket.create("ipv4"); @@ -375,11 +444,16 @@ async function main(): Promise { // real dispatch), so the local address is real immediately after. const stream = await socket.listen(); const addr = socket.getLocalAddress(); - assert(addr.kind === "ipv4" && addr.value.port !== 0, "tcp listen: ephemeral port"); + assert( + addr.kind === "ipv4" && addr.value.port !== 0, + "tcp listen: ephemeral port", + ); assert(socket.getIsListening(), "tcp listen: get-is-listening"); const client = (nodeNet as unknown as { - connect(o: { host: string; port: number; allowHalfOpen: boolean }): NodeTestSocket & { + connect( + o: { host: string; port: number; allowHalfOpen: boolean }, + ): NodeTestSocket & { once(event: string, listener: (...args: unknown[]) => void): unknown; }; }).connect({ @@ -418,10 +492,13 @@ async function main(): Promise { socket[Symbol.dispose](); } - console.log(`wasi node smoke: OK (udp + tcp + listen + http + fs on ${version})`); + console.log( + `wasi node smoke: OK (udp + tcp + listen + http + fs on ${version})`, + ); } main().catch((e) => { console.error(String((e as Error)?.stack ?? e)); - (globalThis as unknown as { process: { exit: (code: number) => void } }).process.exit(1); + (globalThis as unknown as { process: { exit: (code: number) => void } }) + .process.exit(1); }); diff --git a/wasi/tests/random_test.ts b/wasi/tests/random_test.ts index 5ddb185..7aa4b8d 100644 --- a/wasi/tests/random_test.ts +++ b/wasi/tests/random_test.ts @@ -81,40 +81,63 @@ Deno.test("random: a virtualized source replaces the CSPRNG, WIT shapes intact", // The mod.ts COMPOSITION form-3 scenario: tests selectively stubbing // randomness while every WIT shape and rule stays enforced. let counter = 0; - const { imports } = random({ source: (len) => Uint8Array.from({ length: len }, () => counter++) }); + const { imports } = random({ + source: (len) => Uint8Array.from({ length: len }, () => counter++), + }); const r = imports["wasi:random/random@0.2"] as { getRandomBytes(len: bigint): Uint8Array; getRandomU64(): bigint; }; - assertEq(JSON.stringify([...r.getRandomBytes(4n)]), JSON.stringify([0, 1, 2, 3])); + assertEq( + JSON.stringify([...r.getRandomBytes(4n)]), + JSON.stringify([0, 1, 2, 3]), + ); const u64 = r.getRandomU64(); // bytes 4..11, little-endian - assertEq(u64, new DataView(Uint8Array.from([4, 5, 6, 7, 8, 9, 10, 11]).buffer).getBigUint64(0, true)); + assertEq( + u64, + new DataView(Uint8Array.from([4, 5, 6, 7, 8, 9, 10, 11]).buffer) + .getBigUint64(0, true), + ); // insecure routes through the same source; insecure-seed stays governed // by its own option. - const insecure = imports["wasi:random/insecure@0.2"] as { getInsecureRandomBytes(len: bigint): Uint8Array }; + const insecure = imports["wasi:random/insecure@0.2"] as { + getInsecureRandomBytes(len: bigint): Uint8Array; + }; assertEq(insecure.getInsecureRandomBytes(2n).length, 2); }); Deno.test("random: a short-reading source is a loud host error, not guest corruption", () => { const { imports } = random({ source: () => new Uint8Array(3) }); - const r = imports["wasi:random/random@0.2"] as { getRandomBytes(len: bigint): Uint8Array }; + const r = imports["wasi:random/random@0.2"] as { + getRandomBytes(len: bigint): Uint8Array; + }; let threw: unknown; try { r.getRandomBytes(8n); } catch (e) { threw = e; } - assertTrue(threw instanceof TypeError, `a TypeError names the contract, got ${threw}`); + assertTrue( + threw instanceof TypeError, + `a TypeError names the contract, got ${threw}`, + ); }); Deno.test("random@0.3: the same three interfaces ride the 0.3 track", () => { const { imports } = random({ insecureSeed: [7n, 8n] }); for (const iface of ["random", "insecure", "insecure-seed"]) { - assertTrue(`wasi:random/${iface}@0.3` in imports, `${iface}@0.3 registered`); + assertTrue( + `wasi:random/${iface}@0.3` in imports, + `${iface}@0.3 registered`, + ); } - const r = imports["wasi:random/random@0.3"] as { getRandomBytes(len: bigint): Uint8Array }; + const r = imports["wasi:random/random@0.3"] as { + getRandomBytes(len: bigint): Uint8Array; + }; // max-len permits short reads; chunk-to-full returns exactly max-len. assertEq(r.getRandomBytes(16n).length, 16); - const seed = imports["wasi:random/insecure-seed@0.3"] as { insecureSeed(): [bigint, bigint] }; + const seed = imports["wasi:random/insecure-seed@0.3"] as { + insecureSeed(): [bigint, bigint]; + }; assertEq(seed.insecureSeed()[0], 7n); }); diff --git a/wasi/tests/sockets_02_test.ts b/wasi/tests/sockets_02_test.ts index b66f7e4..8d7bed4 100644 --- a/wasi/tests/sockets_02_test.ts +++ b/wasi/tests/sockets_02_test.ts @@ -6,7 +6,11 @@ import { ComponentException } from "@polyengine/protocol"; import type { Pollable } from "../src/io.ts"; -import { type IpSocketAddress, SocketIoError, sockets } from "../src/sockets.ts"; +import { + type IpSocketAddress, + SocketIoError, + sockets, +} from "../src/sockets.ts"; import { assertEq, assertThrows, assertTrue } from "./asserts.ts"; const { imports } = sockets(); @@ -43,15 +47,25 @@ interface Udp02 { startBind(net: Net, addr: IpSocketAddress): void; finishBind(): void; stream(remote?: IpSocketAddress): [ - { receive(max: bigint): { data: Uint8Array; remoteAddress: IpSocketAddress }[]; subscribe(): Pollable }, - { checkSend(): bigint; send(d: { data: Uint8Array; remoteAddress?: IpSocketAddress }[]): bigint; subscribe(): Pollable }, + { + receive( + max: bigint, + ): { data: Uint8Array; remoteAddress: IpSocketAddress }[]; + subscribe(): Pollable; + }, + { + checkSend(): bigint; + send(d: { data: Uint8Array; remoteAddress?: IpSocketAddress }[]): bigint; + subscribe(): Pollable; + }, ]; localAddress(): IpSocketAddress; [Symbol.dispose](): void; } -const net = (imports["wasi:sockets/instance-network@0.2"] as { instanceNetwork(): Net }) - .instanceNetwork(); +const net = + (imports["wasi:sockets/instance-network@0.2"] as { instanceNetwork(): Net }) + .instanceNetwork(); const { createTcpSocket } = imports["wasi:sockets/tcp-create-socket@0.2"] as { createTcpSocket(f: string): Tcp02; }; @@ -68,7 +82,10 @@ const nameLookup = imports["wasi:sockets/ip-name-lookup@0.2"] as { }; }; -const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ +const v4 = ( + address: [number, number, number, number], + port: number, +): IpSocketAddress => ({ kind: "ipv4", value: { port, address }, }); @@ -77,9 +94,15 @@ const LOOPBACK = v4([127, 0, 0, 1], 0); /** 0.2 err payloads are BARE enum strings. */ function errCode(fn: () => unknown): string { const e = assertThrows(fn); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); const payload = (e as ComponentException).payload; - assertTrue(typeof payload === "string", `expected a bare enum string, got ${JSON.stringify(payload)}`); + assertTrue( + typeof payload === "string", + `expected a bare enum string, got ${JSON.stringify(payload)}`, + ); return payload as string; } @@ -157,7 +180,10 @@ Deno.test("tcp02: dial + accept + byte streams + FIN, poll-shaped end to end", a const next = inStream.read(64n); if (next.length > 0) inbound.push(...next); } catch (e) { - assertEq(((e as ComponentException).payload as { kind: string }).kind, "closed"); + assertEq( + ((e as ComponentException).payload as { kind: string }).kind, + "closed", + ); break; } } @@ -209,7 +235,10 @@ Deno.test("udp02: sync bind, stream generations, connected-mode filter", async ( const [bIn, bOut] = b.stream(aAddr); // b is connected to a assertTrue(aOut.checkSend() > 0n, "a send permit"); - assertEq(aOut.send([{ data: Uint8Array.from([1]), remoteAddress: bAddr }]), 1n); + assertEq( + aOut.send([{ data: Uint8Array.from([1]), remoteAddress: bAddr }]), + 1n, + ); await settled(bIn.subscribe()); const got = bIn.receive(8n); assertEq(got.length, 1); @@ -218,7 +247,9 @@ Deno.test("udp02: sync bind, stream generations, connected-mode filter", async ( // Connected sends omit the remote; a mismatched explicit one is invalid. assertEq(bOut.send([{ data: Uint8Array.from([2]) }]), 1n); assertEq( - errCode(() => bOut.send([{ data: new Uint8Array(1), remoteAddress: bAddr }])), + errCode(() => + bOut.send([{ data: new Uint8Array(1), remoteAddress: bAddr }]) + ), "invalid-argument", ); await settled(aIn.subscribe()); @@ -239,22 +270,34 @@ Deno.test("udp02: unconnected send requires a remote (bare-string invalid-argume socket.startBind(net, LOOPBACK); socket.finishBind(); const [, out] = socket.stream(undefined); - assertEq(errCode(() => out.send([{ data: new Uint8Array(1) }])), "invalid-argument"); + assertEq( + errCode(() => out.send([{ data: new Uint8Array(1) }])), + "invalid-argument", + ); socket[Symbol.dispose](); }); Deno.test("ip-name-lookup02: literals answer synchronously; misses fail after the pollable", async () => { const literal = nameLookup.resolveAddresses(net, "127.0.0.1"); const first = literal.resolveNextAddress(); - assertEq(JSON.stringify(first), JSON.stringify({ kind: "ipv4", value: [127, 0, 0, 1] })); + assertEq( + JSON.stringify(first), + JSON.stringify({ kind: "ipv4", value: [127, 0, 0, 1] }), + ); assertEq(literal.resolveNextAddress(), undefined); // end of stream - const miss = nameLookup.resolveAddresses(net, "definitely-not-a-real-host.invalid"); + const miss = nameLookup.resolveAddresses( + net, + "definitely-not-a-real-host.invalid", + ); await settled(miss.subscribe()); assertEq(errCode(() => miss.resolveNextAddress()), "name-unresolvable"); }); Deno.test("network02: error-code downcast recognizes exactly our stream errors", () => { - assertEq(networkErrorCode(new SocketIoError("connection-reset", "peer reset")), "connection-reset"); + assertEq( + networkErrorCode(new SocketIoError("connection-reset", "peer reset")), + "connection-reset", + ); assertEq(networkErrorCode(new Error("random")), undefined); }); diff --git a/wasi/tests/sockets_options_test.ts b/wasi/tests/sockets_options_test.ts index 98620b2..66b8997 100644 --- a/wasi/tests/sockets_options_test.ts +++ b/wasi/tests/sockets_options_test.ts @@ -15,25 +15,39 @@ import { type TcpSocket, type UdpSocket, } from "../src/sockets.ts"; -import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; +import { + assertEq, + assertRejects, + assertThrows, + assertTrue, +} from "./asserts.ts"; const { UdpSocket, TcpSocket, resolveAddresses } = sockets(); -const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ +const v4 = ( + address: [number, number, number, number], + port: number, +): IpSocketAddress => ({ kind: "ipv4", value: { port, address }, }); function errKind(fn: () => unknown): string { const e = assertThrows(fn); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } async function errKindAsync(p: Promise): Promise { const e = await assertRejects(() => p); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } function boundV4(): { socket: UdpSocket; addr: IpSocketAddress } { @@ -48,7 +62,10 @@ Deno.test("udp connect: send with no remote reaches the connected peer", async ( const peer = boundV4(); const socket = UdpSocket.create("ipv4"); await socket.connect(peer.addr); // implicit wildcard bind + OS connect - assertEq(JSON.stringify(socket.getRemoteAddress()), JSON.stringify(peer.addr)); + assertEq( + JSON.stringify(socket.getRemoteAddress()), + JSON.stringify(peer.addr), + ); await socket.send(Uint8Array.from([1, 2, 3]), undefined); const [payload, from] = await peer.socket.receive(); assertEq([...payload].join(","), "1,2,3"); @@ -64,7 +81,10 @@ Deno.test("udp connect: an explicit remote on a connected socket is invalid-argu const peer = boundV4(); const socket = UdpSocket.create("ipv4"); await socket.connect(peer.addr); - assertEq(await errKindAsync(socket.send(new Uint8Array(1), peer.addr)), "invalid-argument"); + assertEq( + await errKindAsync(socket.send(new Uint8Array(1), peer.addr)), + "invalid-argument", + ); socket[Symbol.dispose](); peer.socket[Symbol.dispose](); }); @@ -93,7 +113,10 @@ Deno.test("udp disconnect: back to unconnected — sends need a remote again", a await socket.connect(peer.addr); socket.disconnect(); assertEq(errKind(() => socket.getRemoteAddress()), "invalid-state"); - assertEq(await errKindAsync(socket.send(new Uint8Array(1), undefined)), "invalid-argument"); + assertEq( + await errKindAsync(socket.send(new Uint8Array(1), undefined)), + "invalid-argument", + ); await socket.send(Uint8Array.from([4]), peer.addr); // explicit works again const [payload] = await peer.socket.receive(); assertEq(payload.length, 1); @@ -129,7 +152,10 @@ Deno.test("udp options: buffer sizes are live once bound; zero is invalid", () = const initial = socket.getReceiveBufferSize(); assertTrue(initial > 0n, "a bound socket reports a real SO_RCVBUF"); socket.setReceiveBufferSize(65536n); - assertTrue(socket.getReceiveBufferSize() >= 65536n, "kernel may double, never shrink below"); + assertTrue( + socket.getReceiveBufferSize() >= 65536n, + "kernel may double, never shrink below", + ); socket.setSendBufferSize(65536n); assertTrue(socket.getSendBufferSize() >= 65536n, "SO_SNDBUF applied"); assertEq(errKind(() => socket.setSendBufferSize(0n)), "invalid-argument"); @@ -200,13 +226,19 @@ Deno.test("tcp options: the no-node-API set fails not-supported, never emulates" // --- ip-name-lookup -------------------------------------------------------------- function lookupErrKind(e: unknown): NameLookupErrorCode["kind"] { - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } Deno.test("resolve-addresses: IP literals answer locally, both families", async () => { const [a] = await resolveAddresses("127.0.0.1"); - assertEq(JSON.stringify(a), JSON.stringify({ kind: "ipv4", value: [127, 0, 0, 1] })); + assertEq( + JSON.stringify(a), + JSON.stringify({ kind: "ipv4", value: [127, 0, 0, 1] }), + ); const [b] = await resolveAddresses("::1"); assertEq( JSON.stringify(b), @@ -227,10 +259,15 @@ Deno.test("resolve-addresses: localhost resolves to loopback(s)", async () => { }); Deno.test("resolve-addresses: failures are branded with the lookup vocabulary", async () => { - assertEq(lookupErrKind(await assertRejects(() => resolveAddresses(""))), "invalid-argument"); + assertEq( + lookupErrKind(await assertRejects(() => resolveAddresses(""))), + "invalid-argument", + ); assertEq( lookupErrKind( - await assertRejects(() => resolveAddresses("definitely-not-a-real-host.invalid")), + await assertRejects(() => + resolveAddresses("definitely-not-a-real-host.invalid") + ), ), "name-unresolvable", ); diff --git a/wasi/tests/sockets_tcp_test.ts b/wasi/tests/sockets_tcp_test.ts index c3027b4..2cb96b6 100644 --- a/wasi/tests/sockets_tcp_test.ts +++ b/wasi/tests/sockets_tcp_test.ts @@ -23,7 +23,10 @@ import { assertEq, assertThrows, assertTrue } from "./asserts.ts"; const { TcpSocket } = sockets(); -const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ +const v4 = ( + address: [number, number, number, number], + port: number, +): IpSocketAddress => ({ kind: "ipv4", value: { port, address }, }); @@ -40,16 +43,22 @@ const v6 = ( /** The payload kind of a thrown, branded socket error. */ function errKind(fn: () => unknown): string { const e = assertThrows(fn); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } async function errKindAsync(p: Promise): Promise { try { await p; } catch (e) { - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } throw new Error("expected a rejection"); } @@ -68,9 +77,13 @@ async function* chunksOf(...chunks: number[][]): AsyncGenerator { for (const c of chunks) yield Uint8Array.from(c); } -async function collect(stream: AsyncIterable | Iterable): Promise { +async function collect( + stream: AsyncIterable | Iterable, +): Promise { const out: number[] = []; - for await (const chunk of stream as AsyncIterable) out.push(...chunk); + for await (const chunk of stream as AsyncIterable) { + out.push(...chunk); + } return out; } @@ -82,7 +95,11 @@ interface TestServer { /** A one-connection loopback server driving `handler` on the accepted conn. */ function tcpServer(handler: (conn: Deno.TcpConn) => Promise): TestServer { - const listener = Deno.listen({ transport: "tcp", hostname: "127.0.0.1", port: 0 }); + const listener = Deno.listen({ + transport: "tcp", + hostname: "127.0.0.1", + port: 0, + }); const { port } = listener.addr as Deno.NetAddr; const done = (async () => { const conn = await listener.accept(); @@ -97,7 +114,11 @@ function tcpServer(handler: (conn: Deno.TcpConn) => Promise): TestServer { listener.close(); } })(); - return { addr: v4([127, 0, 0, 1], port), done, close: () => listener.close() }; + return { + addr: v4([127, 0, 0, 1], port), + done, + close: () => listener.close(), + }; } /** Echo until EOF, then close (FIN back). */ @@ -131,7 +152,10 @@ Deno.test("tcp: connect / send / receive — loopback echo, both futures ok", as // the transmission report. Never awaited before the reads — the test // mirrors the guest's concurrent pumps. const txDone = socket.send(chunksOf([1, 2, 3], [4, 5])); - assertEq(JSON.stringify(await collect(rx)), JSON.stringify([1, 2, 3, 4, 5])); + assertEq( + JSON.stringify(await collect(rx)), + JSON.stringify([1, 2, 3, 4, 5]), + ); assertEq((await txDone).kind, "ok"); assertEq((await rxDone).kind, "ok"); await server.done; @@ -144,8 +168,14 @@ Deno.test("tcp: addresses report the real endpoints once connected", async () => const { socket, server } = await connected(echoHandler); try { const local = socket.getLocalAddress(); - assertTrue(local.kind === "ipv4" && local.value.port !== 0, "local port assigned"); - assertEq(JSON.stringify(socket.getRemoteAddress()), JSON.stringify(server.addr)); + assertTrue( + local.kind === "ipv4" && local.value.port !== 0, + "local port assigned", + ); + assertEq( + JSON.stringify(socket.getRemoteAddress()), + JSON.stringify(server.addr), + ); assertEq(socket.getAddressFamily(), "ipv4"); assertEq(socket.getIsListening(), false); // End the exchange so the server task retires. @@ -242,7 +272,8 @@ Deno.test("tcp: a write on a peer-closed connection settles the send future as e assertEq(result.kind, "err"); const kind = resultErrKind(result); assertTrue( - kind === "connection-reset" || kind === "connection-broken" || kind === "invalid-state", + kind === "connection-reset" || kind === "connection-broken" || + kind === "invalid-state", `a connection-failure kind, got ${kind}`, ); } finally { @@ -256,16 +287,29 @@ Deno.test("tcp: connect argument validation (branded)", async () => { const socket = TcpSocket.create("ipv4"); const v6Socket = TcpSocket.create("ipv6"); try { - assertEq(await errKindAsync(socket.connect(v6([0, 0, 0, 0, 0, 0, 0, 1], 9))), "invalid-argument"); - assertEq(await errKindAsync(socket.connect(v4([0, 0, 0, 0], 9))), "invalid-argument"); - assertEq(await errKindAsync(socket.connect(v4([127, 0, 0, 1], 0))), "invalid-argument"); assertEq( - await errKindAsync(v6Socket.connect(v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], 9, 3))), + await errKindAsync(socket.connect(v6([0, 0, 0, 0, 0, 0, 0, 1], 9))), + "invalid-argument", + ); + assertEq( + await errKindAsync(socket.connect(v4([0, 0, 0, 0], 9))), + "invalid-argument", + ); + assertEq( + await errKindAsync(socket.connect(v4([127, 0, 0, 1], 0))), + "invalid-argument", + ); + assertEq( + await errKindAsync( + v6Socket.connect(v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], 9, 3)), + ), "not-supported", ); // An IPv4-mapped IPv6 address never crosses the family boundary. assertEq( - await errKindAsync(v6Socket.connect(v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 9))), + await errKindAsync( + v6Socket.connect(v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 9)), + ), "invalid-argument", ); } finally { @@ -276,15 +320,25 @@ Deno.test("tcp: connect argument validation (branded)", async () => { Deno.test("tcp: a refused dial closes the socket; only drop remains valid", async () => { // A port with no listener: bind one, note the port, close it. - const probe = Deno.listen({ transport: "tcp", hostname: "127.0.0.1", port: 0 }); + const probe = Deno.listen({ + transport: "tcp", + hostname: "127.0.0.1", + port: 0, + }); const { port } = probe.addr as Deno.NetAddr; probe.close(); const socket = TcpSocket.create("ipv4"); - assertEq(await errKindAsync(socket.connect(v4([127, 0, 0, 1], port))), "connection-refused"); + assertEq( + await errKindAsync(socket.connect(v4([127, 0, 0, 1], port))), + "connection-refused", + ); // Failed connect -> closed: connect again is invalid-state, and so is the // rest of the surface. - assertEq(await errKindAsync(socket.connect(v4([127, 0, 0, 1], port))), "invalid-state"); + assertEq( + await errKindAsync(socket.connect(v4([127, 0, 0, 1], port))), + "invalid-state", + ); assertEq(resultErrKind(await socket.send(chunksOf([1]))), "invalid-state"); assertEq(errKind(() => socket.getLocalAddress()), "invalid-state"); dispose(socket); @@ -315,7 +369,8 @@ Deno.test("tcp: getters demand the right state (branded)", () => { /** Hide the node builtins from detection (the only backend), restore after. */ function withoutBuiltins(fn: () => T): T { - const proc = (globalThis as { process?: { getBuiltinModule?: unknown } }).process!; + const proc = (globalThis as { process?: { getBuiltinModule?: unknown } }) + .process!; const saved = proc.getBuiltinModule; proc.getBuiltinModule = undefined; try { @@ -430,7 +485,10 @@ Deno.test("tcp: onCall records the driving sequence", async () => { * ref), and drop the handle. Uniform across generator states — never * started, parked in accept, or suspended at a yield. */ -async function retire(socket: TcpSocket, stream: TcpAcceptStream): Promise { +async function retire( + socket: TcpSocket, + stream: TcpAcceptStream, +): Promise { stream.cancel(); for await (const straggler of stream) dispose(straggler); dispose(socket); @@ -456,7 +514,10 @@ Deno.test("tcp listen: implicit ephemeral bind; an accepted socket serves a full const stream = await socket.listen(); assertEq(socket.getIsListening(), true); const addr = socket.getLocalAddress(); - assertTrue(addr.kind === "ipv4" && addr.value.port !== 0, "ephemeral port assigned"); + assertTrue( + addr.kind === "ipv4" && addr.value.port !== 0, + "ephemeral port assigned", + ); // Dial in from a raw client and speak both directions. const client = await Deno.connect({ @@ -508,7 +569,10 @@ Deno.test("tcp listen: bind picks the address; address-in-use surfaces at listen first.bind(v4([127, 0, 0, 1], 0)); const stream = await first.listen(); const addr = first.getLocalAddress(); - assertTrue(addr.kind === "ipv4" && addr.value.address[0] === 127, "bound to loopback"); + assertTrue( + addr.kind === "ipv4" && addr.value.address[0] === 127, + "bound to loopback", + ); const second = TcpSocket.create("ipv4"); second.bind(addr); @@ -526,7 +590,10 @@ Deno.test("tcp listen: state machine (branded)", async () => { assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "invalid-state"); const stream = await socket.listen(); assertEq(await errKindAsync(socket.listen()), "invalid-state"); - assertEq(await errKindAsync(socket.connect(v4([127, 0, 0, 1], 9))), "invalid-state"); + assertEq( + await errKindAsync(socket.connect(v4([127, 0, 0, 1], 9))), + "invalid-state", + ); assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "invalid-state"); // send/receive on a listener: err futures, never throws. assertEq(resultErrKind(await socket.send(chunksOf([1]))), "invalid-state"); @@ -538,7 +605,10 @@ Deno.test("tcp listen: state machine (branded)", async () => { Deno.test("tcp listen: bind validation (branded)", () => { const socket = TcpSocket.create("ipv4"); - assertEq(errKind(() => socket.bind(v6([0, 0, 0, 0, 0, 0, 0, 1], 0))), "invalid-argument"); + assertEq( + errKind(() => socket.bind(v6([0, 0, 0, 0, 0, 0, 0, 1], 0))), + "invalid-argument", + ); const v6Socket = TcpSocket.create("ipv6"); assertEq( errKind(() => v6Socket.bind(v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], 0, 3))), @@ -556,7 +626,11 @@ Deno.test("tcp listen: the accept stream survives the dropped handle (shared own // Drop the guest handle FIRST: the listener must stay open for the // stream (WIT: "The stream returned by listen behaves similarly"). dispose(socket); - const client = await Deno.connect({ transport: "tcp", hostname: "127.0.0.1", port }); + const client = await Deno.connect({ + transport: "tcp", + hostname: "127.0.0.1", + port, + }); const { taken, it } = await acceptN(stream, 1); assertEq(taken.length, 1, "still accepting after the handle drop"); client.close(); @@ -570,7 +644,11 @@ Deno.test("tcp listen: accepted sockets are independent of the listener", async const stream = await socket.listen(); const addr = socket.getLocalAddress(); const port = addr.kind === "ipv4" ? addr.value.port : 0; - const client = await Deno.connect({ transport: "tcp", hostname: "127.0.0.1", port }); + const client = await Deno.connect({ + transport: "tcp", + hostname: "127.0.0.1", + port, + }); const { taken, it } = await acceptN(stream, 1); const accepted = taken[0]; void it; @@ -629,7 +707,7 @@ Deno.test("tcp: connect from a bound socket dials with the chosen source port", // FIN via the wrapper's close. }); // Random high ports; retry the rare collision. - for (let attempt = 0; ; attempt++) { + for (let attempt = 0;; attempt++) { const want = 20000 + Math.floor(Math.random() * 30000); const socket = TcpSocket.create("ipv4"); socket.bind(v4([127, 0, 0, 1], want)); @@ -638,15 +716,24 @@ Deno.test("tcp: connect from a bound socket dials with the chosen source port", } catch (e) { dispose(socket); const kind = (e as ComponentException).payload?.kind; - if ((kind === "address-in-use" || kind === "address-not-bindable") && attempt < 4) continue; + if ( + (kind === "address-in-use" || kind === "address-not-bindable") && + attempt < 4 + ) continue; throw e; } const local = socket.getLocalAddress(); - assertTrue(local.kind === "ipv4" && local.value.port === want, "our own view shows the port"); + assertTrue( + local.kind === "ipv4" && local.value.port === want, + "our own view shows the port", + ); const [rx, rxDone] = socket.receive(); const seen = await collect(rx); - assertEq(JSON.stringify(seen), JSON.stringify([want >> 8, want & 0xff]), - "the PEER observed the chosen source port"); + assertEq( + JSON.stringify(seen), + JSON.stringify([want >> 8, want & 0xff]), + "the PEER observed the chosen source port", + ); await rxDone; const txDone = socket.send(chunksOf()); await txDone; diff --git a/wasi/tests/sockets_test.ts b/wasi/tests/sockets_test.ts index aeff71a..fd47246 100644 --- a/wasi/tests/sockets_test.ts +++ b/wasi/tests/sockets_test.ts @@ -23,11 +23,19 @@ import { SOCKETS_TYPES_INTERFACE, type UdpSocket, } from "../src/sockets.ts"; -import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; +import { + assertEq, + assertRejects, + assertThrows, + assertTrue, +} from "./asserts.ts"; const { UdpSocket } = sockets(); -const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ +const v4 = ( + address: [number, number, number, number], + port: number, +): IpSocketAddress => ({ kind: "ipv4", value: { port, address }, }); @@ -42,21 +50,31 @@ const v6 = ( }); /** Structural equality, the package test convention (io_test.ts). */ -function assertAddrEq(actual: IpSocketAddress, expected: IpSocketAddress, msg?: string): void { +function assertAddrEq( + actual: IpSocketAddress, + expected: IpSocketAddress, + msg?: string, +): void { assertEq(JSON.stringify(actual), JSON.stringify(expected), msg); } /** The payload kind of a thrown, branded socket error. */ function errKind(fn: () => unknown): string { const e = assertThrows(fn); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } async function errKindAsync(p: Promise): Promise { const e = await assertRejects(() => p); - assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); - return ((e as ComponentException).payload).kind; + assertTrue( + e instanceof ComponentException, + `expected ComponentException, got ${e}`, + ); + return (e as ComponentException).payload.kind; } function dispose(socket: UdpSocket): void { @@ -75,7 +93,10 @@ function boundV4(): { socket: UdpSocket; addr: IpSocketAddress } { Deno.test("codec: IPv4 round trip", () => { const addr = v4([127, 0, 0, 1], 4242); assertEq(ipHostname(addr), "127.0.0.1"); - assertAddrEq(parseNetAddr({ transport: "udp", hostname: "127.0.0.1", port: 4242 }), addr); + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "127.0.0.1", port: 4242 }), + addr, + ); }); Deno.test("codec: IPv6 hostname spellings", () => { @@ -89,7 +110,11 @@ Deno.test("codec: IPv6 hostname spellings", () => { v6([0, 0, 0, 0, 0, 0, 0, 0], port), ); assertAddrEq( - parseNetAddr({ transport: "udp", hostname: "2001:db8::8a2e:370:7334", port }), + parseNetAddr({ + transport: "udp", + hostname: "2001:db8::8a2e:370:7334", + port, + }), v6([0x2001, 0xdb8, 0, 0, 0, 0x8a2e, 0x370, 0x7334], port), ); assertAddrEq( @@ -127,7 +152,10 @@ Deno.test("bind: ephemeral IPv4 loopback, get-local-address reports the port", ( try { assertEq(addr.kind, "ipv4"); if (addr.kind !== "ipv4") return; - assertEq(JSON.stringify(addr.value.address), JSON.stringify([127, 0, 0, 1])); + assertEq( + JSON.stringify(addr.value.address), + JSON.stringify([127, 0, 0, 1]), + ); assertTrue(addr.value.port !== 0, "an ephemeral port was assigned"); } finally { dispose(socket); @@ -141,7 +169,10 @@ Deno.test("bind: ephemeral IPv6 loopback", () => { const addr = socket.getLocalAddress(); assertEq(addr.kind, "ipv6"); if (addr.kind !== "ipv6") return; - assertEq(JSON.stringify(addr.value.address), JSON.stringify([0, 0, 0, 0, 0, 0, 0, 1])); + assertEq( + JSON.stringify(addr.value.address), + JSON.stringify([0, 0, 0, 0, 0, 0, 0, 1]), + ); assertTrue(addr.value.port !== 0); } finally { dispose(socket); @@ -199,7 +230,10 @@ Deno.test("send: implicit bind on an unbound socket", async () => { try { await sender.send(new Uint8Array([9]), listener.addr); const local = sender.getLocalAddress(); - assertTrue(local.kind === "ipv4" && local.value.port !== 0, "send bound the socket"); + assertTrue( + local.kind === "ipv4" && local.value.port !== 0, + "send bound the socket", + ); const [payload] = await listener.socket.receive(); assertEq(JSON.stringify([...payload]), JSON.stringify([9])); } finally { @@ -215,7 +249,9 @@ Deno.test("errors: the datagram-too-large ceiling, both detection paths", async try { // Above the WIT ceiling: refused before the OS. assertEq( - await errKindAsync(socket.send(new Uint8Array(MAX_UDP_DATAGRAM_SIZE + 1), addr)), + await errKindAsync( + socket.send(new Uint8Array(MAX_UDP_DATAGRAM_SIZE + 1), addr), + ), "datagram-too-large", ); // Under the ceiling but above the UDP payload maximum: the OS's @@ -239,7 +275,10 @@ Deno.test("errors: bind is once-only and surfaces address-in-use", () => { const { socket, addr } = boundV4(); const other = UdpSocket.create("ipv4"); try { - assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "invalid-state"); + assertEq( + errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), + "invalid-state", + ); assertEq(errKind(() => other.bind(addr)), "address-in-use"); } finally { dispose(socket); @@ -257,7 +296,9 @@ Deno.test("errors: send argument validation", async () => { ); // Family mismatch, unspecified address, port zero. assertEq( - await errKindAsync(socket.send(new Uint8Array([1]), v6([0, 0, 0, 0, 0, 0, 0, 1], 9))), + await errKindAsync( + socket.send(new Uint8Array([1]), v6([0, 0, 0, 0, 0, 0, 0, 1], 9)), + ), "invalid-argument", ); assertEq( @@ -265,14 +306,19 @@ Deno.test("errors: send argument validation", async () => { "invalid-argument", ); assertEq( - await errKindAsync(socket.send(new Uint8Array([1]), v4([127, 0, 0, 1], 0))), + await errKindAsync( + socket.send(new Uint8Array([1]), v4([127, 0, 0, 1], 0)), + ), "invalid-argument", ); // An IPv4-mapped IPv6 address never crosses the family boundary // (wasmtime-wasi parity). assertEq( await errKindAsync( - v6Socket.send(new Uint8Array([1]), v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 9)), + v6Socket.send( + new Uint8Array([1]), + v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 9), + ), ), "invalid-argument", ); @@ -296,7 +342,8 @@ Deno.test("errors: a non-zero scope-id is not-supported (recorded divergence)", /** Hide the node builtins from detection (the only backend), restore after. */ function withoutBuiltins(fn: () => T): T { - const proc = (globalThis as { process?: { getBuiltinModule?: unknown } }).process!; + const proc = (globalThis as { process?: { getBuiltinModule?: unknown } }) + .process!; const saved = proc.getBuiltinModule; proc.getBuiltinModule = undefined; try { @@ -319,7 +366,10 @@ Deno.test("errors: capability re-detection at bind survives the error mapper", ( const socket = UdpSocket.create("ipv4"); try { withoutBuiltins(() => { - assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "not-supported"); + assertEq( + errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), + "not-supported", + ); }); } finally { dispose(socket); @@ -363,7 +413,11 @@ Deno.test("fragment: registered under the track key; onCall observes the driving socket.getLocalAddress(); assertEq( JSON.stringify(calls), - JSON.stringify(["udp-socket.create", "udp-socket.bind", "udp-socket.get-local-address"]), + JSON.stringify([ + "udp-socket.create", + "udp-socket.bind", + "udp-socket.get-local-address", + ]), ); } finally { dispose(socket); diff --git a/wasi/tests/support/opfs_fake.ts b/wasi/tests/support/opfs_fake.ts index 57d2b56..cf7a499 100644 --- a/wasi/tests/support/opfs_fake.ts +++ b/wasi/tests/support/opfs_fake.ts @@ -52,13 +52,17 @@ export class FakeFileHandle implements OpfsFileHandle { } createWritable(opts?: { keepExistingData?: boolean }): Promise { - let buf = opts?.keepExistingData === true ? this.#data.slice() : new Uint8Array(0); + let buf = opts?.keepExistingData === true + ? this.#data.slice() + : new Uint8Array(0); let open = true; const requireOpen = (): void => { if (!open) throw domError("InvalidStateError", "writable already closed"); }; return Promise.resolve({ - write: (params: { type: "write"; position: number; data: Uint8Array }) => { + write: ( + params: { type: "write"; position: number; data: Uint8Array }, + ) => { requireOpen(); const end = params.position + params.data.length; if (end > buf.length) { @@ -104,7 +108,9 @@ export class FakeDirectoryHandle implements OpfsDirectoryHandle { const existing = this.#children.get(name); if (existing !== undefined) { if (existing.kind !== "directory") { - return Promise.reject(domError("TypeMismatchError", `${name} is a file`)); + return Promise.reject( + domError("TypeMismatchError", `${name} is a file`), + ); } return Promise.resolve(existing); } @@ -116,11 +122,16 @@ export class FakeDirectoryHandle implements OpfsDirectoryHandle { return Promise.resolve(dir); } - getFileHandle(name: string, opts?: { create?: boolean }): Promise { + getFileHandle( + name: string, + opts?: { create?: boolean }, + ): Promise { const existing = this.#children.get(name); if (existing !== undefined) { if (existing.kind !== "file") { - return Promise.reject(domError("TypeMismatchError", `${name} is a directory`)); + return Promise.reject( + domError("TypeMismatchError", `${name} is a directory`), + ); } return Promise.resolve(existing); } @@ -141,13 +152,17 @@ export class FakeDirectoryHandle implements OpfsDirectoryHandle { existing.kind === "directory" && opts?.recursive !== true && existing.childCount() > 0 ) { - return Promise.reject(domError("InvalidModificationError", `${name} not empty`)); + return Promise.reject( + domError("InvalidModificationError", `${name} not empty`), + ); } this.#children.delete(name); return Promise.resolve(); } - async *entries(): AsyncIterable<[string, OpfsDirectoryHandle | OpfsFileHandle]> { + async *entries(): AsyncIterable< + [string, OpfsDirectoryHandle | OpfsFileHandle] + > { for (const [name, handle] of this.#children) yield [name, handle]; } diff --git a/wasi/tests/version_resolution_test.ts b/wasi/tests/version_resolution_test.ts index c051046..8a8de19 100644 --- a/wasi/tests/version_resolution_test.ts +++ b/wasi/tests/version_resolution_test.ts @@ -59,7 +59,9 @@ Deno.test("virtualization: a spread-replaced track key serves the stub; siblings // The stubbed interface resolves to the stub — at any 0.2.x the guest asks. const stubbed = resolver.resolve("wasi:random/random@0.2.9"); assertTrue(stubbed !== undefined); - const provider = stubbed!.value as { getRandomBytes(len: bigint): Uint8Array }; + const provider = stubbed!.value as { + getRandomBytes(len: bigint): Uint8Array; + }; assertEq(provider.getRandomBytes(4n), fixed); // Sibling interfaces from the SAME fragment are untouched. const sibling = resolver.resolve("wasi:random/insecure-seed@0.2.9"); @@ -72,7 +74,9 @@ Deno.test("virtualization: track + exact keys on one track are refused, loudly", // adding an exact-versioned sibling (ambiguous; refused at registration). const composed = { ...wasi(), - "wasi:random/random@0.2.9": { getRandomBytes: (): Uint8Array => new Uint8Array(0) }, + "wasi:random/random@0.2.9": { + getRandomBytes: (): Uint8Array => new Uint8Array(0), + }, }; let threw: unknown; try {