@polyengine/wasi 0.1.0-pre.g633468a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ // `wasi:filesystem@0.2` — types (minimal), preopens
2
+ // (contracts/embedder-api.md §"WASI examination", filesystem scope: "types
3
+ // (minimal: descriptor resource type constructible-never, error-code),
4
+ // preopens (get-directories -> [])").
5
+ //
6
+ // Leaf inventory mined from `engine-go/main.wasm` (componentize-go's p2
7
+ // baseline carries the full filesystem surface even though the guest never
8
+ // gets a real directory): `descriptor` and `directory-entry-stream` resource
9
+ // TYPES, several `[method]descriptor.*` leaves, `filesystem-error-code`,
10
+ // `preopens#get-directories`.
11
+ /**
12
+ * `wasi:filesystem/types.descriptor` — constructible-never.
13
+ *
14
+ * `preopens#get-directories` always returns `[]` (below), so no `descriptor`
15
+ * instance can ever reach a guest through this shim; the class exists only
16
+ * so the resource *type* is a legal, structurally-typeable import target.
17
+ * Every method throws if somehow reached — loud, not a silent wrong answer —
18
+ * because reaching one would mean a caller found a descriptor this shim never
19
+ * handed out.
20
+ */
21
+ export class Descriptor {
22
+ constructor() {
23
+ throw new TypeError("wasi:filesystem/types.descriptor is never constructed by this shim " +
24
+ "(preopens#get-directories always returns [] — CONTRACT: " +
25
+ "contracts/embedder-api.md 'WASI examination' filesystem scope calls " +
26
+ "the type 'constructible-never')");
27
+ }
28
+ #unreachable(method) {
29
+ throw new TypeError(`wasi:filesystem/types#[method]descriptor.${method}: unreachable — ` +
30
+ `no descriptor instance can exist (preopens is empty)`);
31
+ }
32
+ getFlags() {
33
+ return this.#unreachable("get-flags");
34
+ }
35
+ getType() {
36
+ return this.#unreachable("get-type");
37
+ }
38
+ stat() {
39
+ return this.#unreachable("stat");
40
+ }
41
+ statAt() {
42
+ return this.#unreachable("stat-at");
43
+ }
44
+ openAt() {
45
+ return this.#unreachable("open-at");
46
+ }
47
+ readViaStream() {
48
+ return this.#unreachable("read-via-stream");
49
+ }
50
+ writeViaStream() {
51
+ return this.#unreachable("write-via-stream");
52
+ }
53
+ appendViaStream() {
54
+ return this.#unreachable("append-via-stream");
55
+ }
56
+ metadataHashAt() {
57
+ return this.#unreachable("metadata-hash-at");
58
+ }
59
+ }
60
+ /** `wasi:filesystem/types.directory-entry-stream` — same rationale as `Descriptor`. */
61
+ export class DirectoryEntryStream {
62
+ }
63
+ /** `wasi:filesystem@0.2` provider fragment (track key). */
64
+ export function filesystem() {
65
+ return {
66
+ imports: {
67
+ "wasi:filesystem/types@0.2": {
68
+ Descriptor,
69
+ DirectoryEntryStream,
70
+ // `filesystem-error-code: func(err: borrow<error>) -> option<error-code>`
71
+ // — this shim's `wasi:io/error` never produces a filesystem-specific
72
+ // error, so downcasting always fails (`none` -> `undefined`).
73
+ filesystemErrorCode: (_err) => undefined,
74
+ },
75
+ "wasi:filesystem/preopens@0.2": {
76
+ // No preopened directories: consumer guests that never touch the
77
+ // filesystem (the corpus under test) link this leaf but never call
78
+ // a descriptor method.
79
+ getDirectories: () => [],
80
+ },
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,463 @@
1
+ // `@polyengine/wasi/filesystem-node` — `wasi:filesystem@0.2` + `@0.3` over the
2
+ // node `node:fs` builtin (via `process.getBuiltinModule`: real Node and
3
+ // Deno's stable node compat alike — the node-builtins-everywhere stance of
4
+ // sockets_platform.ts). It grants HOST FILESYSTEM access, so it never
5
+ // rides the default `wasi()` merge; preopens are explicit grants:
6
+ //
7
+ // instantiate(a, { ...wasi(), ...filesystemNode({ preopens: { "/": "./sandbox" } }).imports })
8
+ //
9
+ // READ-ONLY BY DEFAULT: the grant above is read-only. Writes need the
10
+ // package-level `writable: true` — one flag for the whole
11
+ // implementation, never per-preopen (fs_provider.ts header for why, and
12
+ // for the enforcement site: it is the provider, not this backend).
13
+ //
14
+ // SYNC BY CONSTRUCTION: every backend op uses node's `*Sync` API, so the
15
+ // 0.2 track's sync WIT functions are served without parking — guests run
16
+ // in plain callback mode, no JSPI required (the A14 marks stay off; see
17
+ // fs_provider.ts). The 0.3 track returns plain values from async funcs,
18
+ // which the runtime accepts.
19
+ //
20
+ // SECURITY: this containment is a CORRECTNESS mechanism, not a security
21
+ // boundary — see docs/security.md before granting a guest host access.
22
+ // It cannot see hardlinks or bind mounts (both resolve "inside" by every
23
+ // path-shaped measure) and it loses cross-process races. Guest paths are
24
+ // confined TEXTUALLY by fs_provider.ts ("`..`"
25
+ // cannot escape), and this backend adds PHYSICAL containment on top:
26
+ // every path-taking op realpaths the parent directory (and, when
27
+ // symlink-follow is set, chases the final component's link chain) and
28
+ // refuses — `not-permitted` — anything that resolves outside the
29
+ // preopen's realpath root. Symlinks the guest creates itself therefore
30
+ // cannot be used to read or write outside the preopen, and neither can
31
+ // symlinks that were already in the preopened tree (issue #177).
32
+ //
33
+ // Residual risk is cross-process TOCTOU: node has no openat2 /
34
+ // RESOLVE_BENEATH analogue, so between the realpath check and the OS
35
+ // call another PROCESS could swap a component for a symlink. The guest
36
+ // itself cannot interleave — every backend op is synchronous on the
37
+ // guest's own thread — so this is only reachable when something else
38
+ // with write access to the preopened tree races us.
39
+ //
40
+ // PLATFORM TRAPS the containment code deliberately works around — both
41
+ // observed on Deno's node compat, both silent, both only when the
42
+ // process runs WITHOUT blanket read/write permission (i.e. under this
43
+ // package's own `deno task test` flags, which is how they were found):
44
+ // * `openSync` drops `O_NOFOLLOW`, so a nofollow open of a symlink
45
+ // opens the target. `openAt` therefore raises ELOOP itself after an
46
+ // lstat rather than trusting the flag.
47
+ // * `realpathSync` normalizes `..` LEXICALLY, so
48
+ // `realpathSync("<root>/link-pointing-out/..")` answers `<root>`
49
+ // instead of the outside parent. Nothing here ever hands a `..` to
50
+ // realpathSync: link targets are walked one component at a time
51
+ // (see `walkReal`).
52
+ //
53
+ // Fidelity notes: `append` stats-then-writes (not O_APPEND atomic);
54
+ // set-times converts to seconds-resolution node utimes (ns precision is
55
+ // reported by stat but not settable); `..` resolves textually, not
56
+ // physically through symlinked intermediates.
57
+ import { makeFilesystem, } from "./internal/fs_provider.js";
58
+ /** `process.getBuiltinModule(name)`, if this host has it (Node, Deno, Bun). */
59
+ function nodeBuiltin(name) {
60
+ const proc = globalThis.process;
61
+ const get = proc?.getBuiltinModule;
62
+ return get === undefined ? undefined : get.call(proc, name);
63
+ }
64
+ // --- errno mapping ---------------------------------------------------------------
65
+ const ERRNO_MAP = {
66
+ EACCES: "access",
67
+ EPERM: "not-permitted",
68
+ ENOENT: "no-entry",
69
+ EEXIST: "exist",
70
+ ENOTDIR: "not-directory",
71
+ EISDIR: "is-directory",
72
+ ENOTEMPTY: "not-empty",
73
+ EINVAL: "invalid",
74
+ ELOOP: "loop",
75
+ EXDEV: "cross-device",
76
+ ENAMETOOLONG: "name-too-long",
77
+ EBUSY: "busy",
78
+ EROFS: "read-only",
79
+ EBADF: "bad-descriptor",
80
+ EFBIG: "file-too-large",
81
+ ENOSPC: "insufficient-space",
82
+ EDQUOT: "quota",
83
+ EMLINK: "too-many-links",
84
+ ESPIPE: "invalid-seek",
85
+ ENXIO: "no-such-device",
86
+ ENODEV: "no-device",
87
+ ETXTBSY: "text-file-busy",
88
+ EOVERFLOW: "overflow",
89
+ EINTR: "interrupted",
90
+ EAGAIN: "would-block",
91
+ ENOMEM: "insufficient-memory",
92
+ ENOTSUP: "unsupported",
93
+ EOPNOTSUPP: "unsupported",
94
+ EILSEQ: "illegal-byte-sequence",
95
+ };
96
+ function direntType(d) {
97
+ if (d.isDirectory())
98
+ return "directory";
99
+ if (d.isFile())
100
+ return "regular-file";
101
+ if (d.isSymbolicLink())
102
+ return "symbolic-link";
103
+ if (d.isBlockDevice())
104
+ return "block-device";
105
+ if (d.isCharacterDevice())
106
+ return "character-device";
107
+ if (d.isFIFO())
108
+ return "fifo";
109
+ if (d.isSocket())
110
+ return "socket";
111
+ return "unknown";
112
+ }
113
+ function makeNodeBackend(fs, path) {
114
+ const join = (base, segments) => segments.length === 0 ? base.path : `${base.path}/${segments.join("/")}`;
115
+ // --- physical containment (issue #177) -----------------------------------------
116
+ //
117
+ // fs_provider.ts confines guest paths textually; the OS still resolves
118
+ // symlinks, so containment has to be re-established against REAL paths
119
+ // before every OS call. `not-permitted` is the escape code, matching
120
+ // parsePath's choice for `..` underflow and absolute paths. Raw EPERM
121
+ // is what we throw: mapError names it, so both WIT tracks shape it.
122
+ const escape = (full) => Object.assign(new Error(`path escapes the preopen: ${full}`), { code: "EPERM" });
123
+ /** `real` must be the root itself or strictly beneath it. */
124
+ const requireInside = (real, root, full) => {
125
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
126
+ if (real !== root && !real.startsWith(prefix))
127
+ throw escape(full);
128
+ };
129
+ const errCode = (e) => e?.code;
130
+ const joinReal = (dir, seg) => dir.endsWith(path.sep) ? `${dir}${seg}` : `${dir}${path.sep}${seg}`;
131
+ /**
132
+ * Walk `rel` from the ALREADY-REALPATHED directory `dir`, one
133
+ * component at a time, keeping the accumulated directory a realpath at
134
+ * every step and requiring containment at every step.
135
+ *
136
+ * Why component-wise, and why `..` is handled here rather than handed
137
+ * to the OS: `..` is where lexical and physical resolution diverge —
138
+ * the kernel applies it after traversing the preceding component, so
139
+ * with `esc` an escaping symlink, `esc/..` is the OUTSIDE parent while
140
+ * any string collapse says "back where we started". realpathSync is
141
+ * the physical authority, but it cannot be handed a `..` either:
142
+ * Deno's node-compat realpathSync normalizes `..` LEXICALLY when the
143
+ * process runs without blanket read permission — exactly the flags
144
+ * `deno task test` uses — so `realpathSync("<root>/esc/..")` answers
145
+ * `<root>` there and the outside parent under `-A`. Resolving one
146
+ * component at a time keeps every string we hand to node free of
147
+ * `..`, which is the only form both modes agree on.
148
+ *
149
+ * `..` itself is then a pure lexical `dirname` — and that is exact,
150
+ * because `dir` is a realpath: it contains no symlinks and no `..`,
151
+ * so its lexical parent IS its physical parent.
152
+ */
153
+ const walkReal = (dir, rel, root, full) => {
154
+ let d = dir;
155
+ for (const seg of rel.split(path.sep)) {
156
+ if (seg === "" || seg === ".")
157
+ continue;
158
+ // A climb above the root is refused here rather than allowed to
159
+ // dip back in, matching parsePath's `..`-underflow rule.
160
+ d = seg === ".." ? path.dirname(d) : fs.realpathSync(joinReal(d, seg));
161
+ requireInside(d, root, full);
162
+ }
163
+ return d;
164
+ };
165
+ /**
166
+ * Follow the final component's symlink chain, requiring the result to
167
+ * land inside `root`. `parentReal` is the caller's already-resolved,
168
+ * already-contained parent directory. Dangling chains are chased so a
169
+ * create through `link -> outside/new` cannot plant a file outside.
170
+ */
171
+ const chaseFinal = (full, root, parentReal) => {
172
+ // `dir` is a realpath at all times; `name` is a single component
173
+ // (never "." or ".." — those are folded into `dir` below). `full`
174
+ // itself carries no "." / ".." segments: parsePath removed them
175
+ // before the backend saw the path.
176
+ let dir = parentReal;
177
+ let name = path.basename(full);
178
+ for (let i = 0; i < 40; i++) {
179
+ if (name === "") {
180
+ requireInside(dir, root, full); // chain ended at a directory
181
+ return;
182
+ }
183
+ const cur = joinReal(dir, name);
184
+ try {
185
+ // The whole chain resolved: this is the physical truth the OS
186
+ // call will see, and the string is `..`-free, so both Deno
187
+ // permission modes agree on it. One check settles it.
188
+ requireInside(fs.realpathSync(cur), root, full);
189
+ return;
190
+ }
191
+ catch (e) {
192
+ // EPERM (our escape), ELOOP, ENOTDIR, EACCES … all stand.
193
+ if (errCode(e) !== "ENOENT")
194
+ throw e;
195
+ }
196
+ // ENOENT: either `cur` does not exist (fine — its directory is
197
+ // contained, so a create lands inside), or it is a link whose
198
+ // chain dangles. Only the latter needs chasing.
199
+ let st;
200
+ try {
201
+ st = fs.lstatSync(cur, { bigint: true });
202
+ }
203
+ catch {
204
+ return; // genuinely absent
205
+ }
206
+ if (!st.isSymbolicLink())
207
+ return;
208
+ const target = fs.readlinkSync(cur);
209
+ // Split off the last component TEXTUALLY — this only separates
210
+ // "directory part" from "name", it normalizes nothing. The
211
+ // directory part is then walked physically; absolute and relative
212
+ // targets differ only in where that walk starts.
213
+ const cut = target.lastIndexOf(path.sep);
214
+ name = cut < 0 ? target : target.slice(cut + 1);
215
+ dir = walkReal(path.isAbsolute(target) ? path.sep : dir, cut < 0 ? "" : target.slice(0, cut), root, full);
216
+ if (name === "." || name === "..") {
217
+ dir = walkReal(dir, name, root, full);
218
+ name = "";
219
+ }
220
+ }
221
+ throw Object.assign(new Error("too many symbolic links"), { code: "ELOOP" });
222
+ };
223
+ /**
224
+ * The guard every path-taking op runs before touching the OS: resolve
225
+ * the PARENT physically and require containment; with `follow`, the
226
+ * final component's link chain must stay contained too. Returns the
227
+ * path to hand to node.
228
+ */
229
+ const guard = (base, segments, follow) => {
230
+ const full = join(base, segments);
231
+ if (segments.length === 0) {
232
+ // The base itself (no parent to check — its parent is typically the
233
+ // preopen's own parent, outside the root by construction).
234
+ requireInside(fs.realpathSync(full), base.root, full);
235
+ return full;
236
+ }
237
+ const parentReal = fs.realpathSync(path.dirname(full));
238
+ requireInside(parentReal, base.root, full);
239
+ if (follow)
240
+ chaseFinal(full, base.root, parentReal);
241
+ return full;
242
+ };
243
+ const requireFd = (h) => {
244
+ if (h.fd === undefined) {
245
+ // Directory handles carry no fd; byte ops on one are EISDIR.
246
+ throw Object.assign(new Error("descriptor is a directory"), { code: "EISDIR" });
247
+ }
248
+ return h.fd;
249
+ };
250
+ const statOf = (st) => ({
251
+ type: direntType(st),
252
+ linkCount: st.nlink,
253
+ size: st.size,
254
+ atimeNs: st.atimeNs,
255
+ mtimeNs: st.mtimeNs,
256
+ ctimeNs: st.ctimeNs,
257
+ });
258
+ const statHandle = (h) => h.fd === undefined ? fs.statSync(h.path, { bigint: true }) : fs.fstatSync(h.fd, { bigint: true });
259
+ /** node utimes take seconds (fractional); "no-change" re-applies the
260
+ * current value (POSIX UTIME_OMIT has no node spelling). */
261
+ const timeArgs = (current, atime, mtime) => {
262
+ const now = Date.now() / 1000;
263
+ const secs = (spec, currentNs) => {
264
+ switch (spec.kind) {
265
+ case "no-change":
266
+ return Number(currentNs()) / 1e9;
267
+ case "now":
268
+ return now;
269
+ case "timestamp":
270
+ return Number(spec.ns) / 1e9;
271
+ }
272
+ };
273
+ let st;
274
+ const cached = () => (st ??= current());
275
+ return [
276
+ secs(atime, () => cached().atimeNs),
277
+ secs(mtime, () => cached().mtimeNs),
278
+ ];
279
+ };
280
+ return {
281
+ isSync: true,
282
+ mapError(e) {
283
+ const code = e?.code;
284
+ return (typeof code === "string" ? ERRNO_MAP[code] : undefined) ?? "io";
285
+ },
286
+ openAt(base, segments, opts) {
287
+ // Guarded BEFORE any fs call, including the directory fast path
288
+ // below — which returns a path handle without ever reaching
289
+ // openSync, so O_NOFOLLOW alone never covered directory opens.
290
+ const full = guard(base, segments, opts.follow);
291
+ const root = base.root;
292
+ const c = fs.constants;
293
+ let flags = opts.write ? (opts.read ? c.O_RDWR : c.O_WRONLY) : c.O_RDONLY;
294
+ if (opts.create)
295
+ flags |= c.O_CREAT;
296
+ if (opts.exclusive)
297
+ flags |= c.O_EXCL;
298
+ if (opts.truncate)
299
+ flags |= c.O_TRUNC;
300
+ if (!opts.follow)
301
+ flags |= c.O_NOFOLLOW;
302
+ if (opts.directory)
303
+ flags |= c.O_DIRECTORY;
304
+ // Directories: path handles (no fd — every dir op is path-based).
305
+ const st = (opts.follow ? fs.statSync : fs.lstatSync).bind(fs);
306
+ let existing;
307
+ try {
308
+ existing = st(full, { bigint: true });
309
+ }
310
+ catch {
311
+ existing = undefined; // may be about to be created
312
+ }
313
+ if (!opts.follow && existing?.isSymbolicLink()) {
314
+ // O_NOFOLLOW is NOT dependable here: Deno's node:fs compat drops
315
+ // the flag when the process runs without blanket read/write
316
+ // permission (openSync then happily opens the link target). The
317
+ // POSIX answer for a nofollow open of a symlink is ELOOP, so we
318
+ // give it ourselves rather than trusting the flag.
319
+ throw Object.assign(new Error("nofollow open of a symbolic link"), { code: "ELOOP" });
320
+ }
321
+ if (existing?.isDirectory()) {
322
+ if (opts.exclusive && opts.create) {
323
+ throw Object.assign(new Error("exists"), { code: "EEXIST" });
324
+ }
325
+ return { handle: { path: full, root, type: "directory" }, type: "directory" };
326
+ }
327
+ if (opts.directory && existing !== undefined) {
328
+ throw Object.assign(new Error("not a directory"), { code: "ENOTDIR" });
329
+ }
330
+ const fd = fs.openSync(full, flags);
331
+ const opened = fs.fstatSync(fd, { bigint: true });
332
+ const type = direntType(opened);
333
+ if (type === "directory") {
334
+ // Raced into a directory: fall back to a path handle.
335
+ fs.closeSync(fd);
336
+ return { handle: { path: full, root, type }, type };
337
+ }
338
+ return { handle: { path: full, root, type, fd }, type };
339
+ },
340
+ close(h) {
341
+ if (h.fd !== undefined)
342
+ fs.closeSync(h.fd);
343
+ },
344
+ stat: (h) => statOf(statHandle(h)),
345
+ statAt(base, segments, follow) {
346
+ const st = (follow ? fs.statSync : fs.lstatSync).bind(fs);
347
+ return statOf(st(guard(base, segments, follow), { bigint: true }));
348
+ },
349
+ read(h, length, offset) {
350
+ const out = new Uint8Array(length);
351
+ const n = fs.readSync(requireFd(h), out, 0, length, offset);
352
+ return out.subarray(0, n);
353
+ },
354
+ write(h, buffer, offset) {
355
+ return fs.writeSync(requireFd(h), buffer, 0, buffer.length, offset);
356
+ },
357
+ append(h, buffer) {
358
+ const fd = requireFd(h);
359
+ const size = Number(fs.fstatSync(fd, { bigint: true }).size);
360
+ return fs.writeSync(fd, buffer, 0, buffer.length, size);
361
+ },
362
+ setSize(h, size) {
363
+ if (h.fd === undefined)
364
+ fs.truncateSync(h.path, size);
365
+ else
366
+ fs.ftruncateSync(h.fd, size);
367
+ },
368
+ setTimes(h, atime, mtime) {
369
+ const [a, m] = timeArgs(() => statHandle(h), atime, mtime);
370
+ if (h.fd === undefined)
371
+ fs.utimesSync(h.path, a, m);
372
+ else
373
+ fs.futimesSync(h.fd, a, m);
374
+ },
375
+ setTimesAt(base, segments, follow, atime, mtime) {
376
+ const full = guard(base, segments, follow);
377
+ const st = (follow ? fs.statSync : fs.lstatSync).bind(fs);
378
+ const [a, m] = timeArgs(() => st(full, { bigint: true }), atime, mtime);
379
+ (follow ? fs.utimesSync : fs.lutimesSync).call(fs, full, a, m);
380
+ },
381
+ syncAll(h) {
382
+ if (h.fd !== undefined)
383
+ fs.fsyncSync(h.fd);
384
+ },
385
+ syncData(h) {
386
+ if (h.fd !== undefined)
387
+ fs.fdatasyncSync(h.fd);
388
+ },
389
+ readDirectory(h) {
390
+ // Handle-only op: re-check the handle's own path (a directory
391
+ // handle is path-based, so it is re-resolved on every listing).
392
+ requireInside(fs.realpathSync(h.path), h.root, h.path);
393
+ return fs.readdirSync(h.path, { withFileTypes: true }).map((d) => ({
394
+ name: d.name,
395
+ type: direntType(d),
396
+ }));
397
+ },
398
+ createDirectoryAt(base, segments) {
399
+ fs.mkdirSync(guard(base, segments, false));
400
+ },
401
+ removeDirectoryAt(base, segments) {
402
+ fs.rmdirSync(guard(base, segments, false));
403
+ },
404
+ unlinkFileAt(base, segments) {
405
+ fs.unlinkSync(guard(base, segments, false));
406
+ },
407
+ renameAt(oldBase, oldSegments, newBase, newSegments) {
408
+ // Both endpoints are guarded; neither op follows the final link.
409
+ fs.renameSync(guard(oldBase, oldSegments, false), guard(newBase, newSegments, false));
410
+ },
411
+ linkAt(oldBase, oldSegments, follow, newBase, newSegments) {
412
+ fs.linkSync(guard(oldBase, oldSegments, follow), guard(newBase, newSegments, false));
413
+ },
414
+ symlinkAt(target, base, segments) {
415
+ // The LINK path is confined; `target` is deliberately not
416
+ // restricted (an absolute or escaping target is inert — every
417
+ // later resolution through it is refused by `guard`). wasmtime-wasi
418
+ // could not be confirmed to reject absolute targets at creation,
419
+ // so we stay permissive: containment is enforced at resolution.
420
+ fs.symlinkSync(target, guard(base, segments, false));
421
+ },
422
+ readlinkAt(base, segments) {
423
+ return fs.readlinkSync(guard(base, segments, false));
424
+ },
425
+ identity(h) {
426
+ const st = statHandle(h);
427
+ return { a: st.dev, b: st.ino };
428
+ },
429
+ identityAt(base, segments, follow) {
430
+ const st = (follow ? fs.statSync : fs.lstatSync).bind(fs);
431
+ const s = st(guard(base, segments, follow), { bigint: true });
432
+ return { a: s.dev, b: s.ino };
433
+ },
434
+ isSame(a, b) {
435
+ const sa = statHandle(a);
436
+ const sb = statHandle(b);
437
+ return sa.dev === sb.dev && sa.ino === sb.ino;
438
+ },
439
+ };
440
+ }
441
+ /**
442
+ * `wasi:filesystem` over node's `node:fs` builtin (module header).
443
+ * Serves both the `@0.2` and `@0.3` tracks.
444
+ */
445
+ export function filesystemNode(options) {
446
+ const fs = nodeBuiltin("node:fs");
447
+ const path = nodeBuiltin("node:path");
448
+ if (fs === undefined || path === undefined) {
449
+ throw new TypeError("filesystemNode: no `process.getBuiltinModule` on this host — " +
450
+ "node:fs and node:path are required (real Node, or Deno's stable " +
451
+ "node compat); browsers want @polyengine/wasi/filesystem-web");
452
+ }
453
+ const preopens = Object.entries(options.preopens).map(([guestName, hostPath]) => {
454
+ const real = fs.realpathSync(hostPath);
455
+ if (!fs.statSync(real, { bigint: true }).isDirectory()) {
456
+ throw new TypeError(`filesystemNode: preopen ${hostPath} is not a directory`);
457
+ }
458
+ return [{ path: real, root: real, type: "directory" }, guestName];
459
+ });
460
+ return makeFilesystem(makeNodeBackend(fs, path), preopens, {
461
+ writable: options.writable === true,
462
+ });
463
+ }