@bytecodealliance/preview3-shim 0.1.0 → 0.1.2

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.
@@ -27,6 +27,7 @@ export const systemClock = {
27
27
  const { subscribeInstant, subscribeDuration, ...baseMonotonicClock } = monotonicClockV2;
28
28
 
29
29
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
30
+ const nsToTimeoutMs = (ns) => (ns + 999_999n) / 1_000_000n;
30
31
 
31
32
  export const monotonicClock = {
32
33
  ...baseMonotonicClock,
@@ -58,19 +59,21 @@ export const monotonicClock = {
58
59
  * @throws {TypeError} If targetNs is not a bigint
59
60
  */
60
61
  async waitUntil(targetNs) {
61
- const nowNs = monotonicClock.now();
62
- const diffNs = targetNs - nowNs;
62
+ while (true) {
63
+ const nowNs = monotonicClock.now();
64
+ const diffNs = targetNs - nowNs;
63
65
 
64
- if (diffNs <= 0n) {
65
- return;
66
- }
66
+ if (diffNs <= 0n) {
67
+ return;
68
+ }
67
69
 
68
- const ms = diffNs / 1_000_000n;
69
- if (ms > BigInt(Number.MAX_SAFE_INTEGER)) {
70
- throw new TypeError(`Cannot wait for ${targetNs} ns, exceeds maximum safe integer`);
71
- }
70
+ const ms = nsToTimeoutMs(diffNs);
71
+ if (ms > BigInt(Number.MAX_SAFE_INTEGER)) {
72
+ throw new TypeError(`Cannot wait for ${targetNs} ns, exceeds maximum safe integer`);
73
+ }
72
74
 
73
- await sleep(Number(ms));
75
+ await sleep(Number(ms));
76
+ }
74
77
  },
75
78
 
76
79
  /**
@@ -90,11 +93,6 @@ export const monotonicClock = {
90
93
  return;
91
94
  }
92
95
 
93
- const ms = durationNs / 1_000_000n;
94
- if (ms > BigInt(Number.MAX_SAFE_INTEGER)) {
95
- throw new TypeError(`Cannot wait for ${durationNs} ns, exceeds maximum safe integer`);
96
- }
97
-
98
- await sleep(Number(ms));
96
+ await monotonicClock.waitUntil(monotonicClock.now() + durationNs);
99
97
  },
100
98
  };
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import nodePath from "node:path";
2
3
  import process from "node:process";
3
4
 
4
5
  import { StreamReader, readableByteStreamFromReader } from "../stream.js";
@@ -29,8 +30,10 @@ class Descriptor {
29
30
  #finalizer;
30
31
  /** Host filesystem path for preopened directories */
31
32
  #hostPreopen;
33
+ /** Whether this descriptor refers to a directory. */
34
+ #isDirectory = false;
32
35
 
33
- static _create(handle, mode, fullPath) {
36
+ static _create(handle, mode, fullPath, isDirectory = false) {
34
37
  const {
35
38
  read = false,
36
39
  write = false,
@@ -52,6 +55,7 @@ class Descriptor {
52
55
  const desc = new Descriptor();
53
56
  desc.#handle = handle;
54
57
  desc.#fullPath = fullPath;
58
+ desc.#isDirectory = isDirectory;
55
59
  desc.#mode = merged;
56
60
  desc.#finalizer = registerDispose(desc, null, handle, (handle) => handle.close());
57
61
 
@@ -60,6 +64,15 @@ class Descriptor {
60
64
 
61
65
  static _createPreopen(hostPreopen) {
62
66
  const desc = new Descriptor();
67
+ desc.#isDirectory = true;
68
+ desc.#mode = {
69
+ read: true,
70
+ write: false,
71
+ fileIntegritySync: false,
72
+ dataIntegritySync: false,
73
+ requestedWriteSync: false,
74
+ mutateDirectory: true,
75
+ };
63
76
 
64
77
  if (hostPreopen.endsWith("/")) {
65
78
  desc.#hostPreopen = hostPreopen.slice(0, -1) || "/";
@@ -363,6 +376,7 @@ class Descriptor {
363
376
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
364
377
  */
365
378
  async createDirectoryAt(path) {
379
+ await this.#ensureSandboxedPath(path);
366
380
  const full = this.#getFullPath(path, false);
367
381
  try {
368
382
  await fs.mkdir(full);
@@ -426,6 +440,7 @@ class Descriptor {
426
440
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
427
441
  */
428
442
  async statAt(flags, path) {
443
+ await this.#ensureSandboxedPath(path);
429
444
  const full = this.#getFullPath(path, flags.symlinkFollow);
430
445
  try {
431
446
  const statFn = flags.symlinkFollow ? fs.stat : fs.lstat;
@@ -464,6 +479,7 @@ class Descriptor {
464
479
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
465
480
  */
466
481
  async setTimesAt(flags, path, atimeDesc, mtimeDesc) {
482
+ await this.#ensureSandboxedPath(path);
467
483
  const { atime, mtime } = await this.#computeTimestamps(atimeDesc, mtimeDesc, path);
468
484
 
469
485
  if (!flags.symlinkFollow && !fs.lutimes) {
@@ -501,6 +517,8 @@ class Descriptor {
501
517
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
502
518
  */
503
519
  async linkAt(oldFlags, oldPath, newDesc, newPath) {
520
+ await this.#ensureSandboxedPath(oldPath);
521
+ await newDesc.#ensureSandboxedPath(newPath);
504
522
  const src = this.#getFullPath(oldPath, oldFlags.symlinkFollow);
505
523
  const dst = newDesc.#getFullPath(newPath, false);
506
524
 
@@ -542,9 +560,16 @@ class Descriptor {
542
560
  throw new FSError("access");
543
561
  }
544
562
 
563
+ await this.#ensureSandboxedPath(path);
545
564
  const fullPath = this.#getFullPath(path, pf.symlinkFollow);
546
565
  const target = stripTrailingSlash(fullPath);
547
566
 
567
+ const mode = {
568
+ ...df,
569
+ read: df.read || !df.write,
570
+ write: df.write || of.create || of.truncate,
571
+ };
572
+
548
573
  const makeFsFlags = () => {
549
574
  let fsFlags = 0;
550
575
  if (of.create) {
@@ -559,11 +584,11 @@ class Descriptor {
559
584
  if (of.truncate) {
560
585
  fsFlags |= fs.constants.O_TRUNC;
561
586
  }
562
- if (df.read && df.write) {
587
+ if (mode.read && mode.write) {
563
588
  fsFlags |= fs.constants.O_RDWR;
564
- } else if (df.write) {
589
+ } else if (mode.write) {
565
590
  fsFlags |= fs.constants.O_WRONLY;
566
- } else if (df.read) {
591
+ } else if (mode.read) {
567
592
  fsFlags |= fs.constants.O_RDONLY;
568
593
  }
569
594
  if (df.fileIntegritySync) {
@@ -610,8 +635,9 @@ class Descriptor {
610
635
 
611
636
  try {
612
637
  const handle = await fs.open(target, fsFlags);
613
- const desc = descriptorCreate(handle, df, fullPath);
614
- const isDir = (await desc.getType()).tag === "directory";
638
+ const stats = await handle.stat();
639
+ const isDir = stats.isDirectory();
640
+ const desc = descriptorCreate(handle, mode, fullPath, isDir);
615
641
 
616
642
  if (fullPath.endsWith("/") && !isDir) {
617
643
  desc[symbolDispose]();
@@ -639,6 +665,7 @@ class Descriptor {
639
665
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
640
666
  */
641
667
  async readlinkAt(path) {
668
+ await this.#ensureSandboxedPath(path);
642
669
  const full = this.#getFullPath(path, false);
643
670
  try {
644
671
  return await fs.readlink(full);
@@ -660,6 +687,10 @@ class Descriptor {
660
687
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
661
688
  */
662
689
  async removeDirectoryAt(path) {
690
+ if (path === ".") {
691
+ throw new FSError("invalid");
692
+ }
693
+ await this.#ensureSandboxedPath(path);
663
694
  const full = this.#getFullPath(path, false);
664
695
  try {
665
696
  await fs.rmdir(full);
@@ -686,6 +717,8 @@ class Descriptor {
686
717
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
687
718
  */
688
719
  async renameAt(oldPath, newDesc, newPath) {
720
+ await this.#ensureSandboxedPath(oldPath);
721
+ await newDesc.#ensureSandboxedPath(newPath);
689
722
  const src = this.#getFullPath(oldPath, false);
690
723
  const dst = newDesc.#getFullPath(newPath, false);
691
724
  try {
@@ -715,6 +748,7 @@ class Descriptor {
715
748
  if (target.startsWith("/")) {
716
749
  throw new FSError("not-permitted");
717
750
  }
751
+ await this.#ensureSandboxedPath(path);
718
752
  const full = this.#getFullPath(path, false);
719
753
 
720
754
  try {
@@ -750,6 +784,7 @@ class Descriptor {
750
784
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
751
785
  */
752
786
  async unlinkFileAt(path) {
787
+ await this.#ensureSandboxedPath(path);
753
788
  const full = this.#getFullPath(path, false);
754
789
  if (full.endsWith("/")) {
755
790
  const isDir = (await fs.stat(full)).isDirectory();
@@ -787,7 +822,8 @@ class Descriptor {
787
822
  * @returns {Promise<boolean>}
788
823
  */
789
824
  async isSameObject(other) {
790
- return other === this;
825
+ const [left, right] = await Promise.all([this.#statForIdentity(), other.#statForIdentity()]);
826
+ return left.dev === right.dev && left.ino === right.ino;
791
827
  }
792
828
 
793
829
  /**
@@ -802,11 +838,8 @@ class Descriptor {
802
838
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
803
839
  */
804
840
  async metadataHash() {
805
- if (this.#hostPreopen) {
806
- return { upper: 0n, lower: BigInt(this._id) };
807
- }
808
841
  try {
809
- const s = await this.#handle.stat();
842
+ const s = await this.#statForIdentity();
810
843
  return { upper: s.mtimeNs, lower: s.ino };
811
844
  } catch (e) {
812
845
  throw FSError.from(e);
@@ -827,10 +860,11 @@ class Descriptor {
827
860
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
828
861
  */
829
862
  async metadataHashAt(flags, path) {
863
+ await this.#ensureSandboxedPath(path);
830
864
  const full = this.#getFullPath(path, false);
831
865
  try {
832
866
  const statFn = flags.symlinkFollow ? fs.stat : fs.lstat;
833
- const s = await statFn(full);
867
+ const s = await statFn(full, { bigint: true });
834
868
  return { upper: s.mtimeNs, lower: s.ino };
835
869
  } catch (e) {
836
870
  throw FSError.from(e);
@@ -896,6 +930,10 @@ class Descriptor {
896
930
  #getFullPath(subpath, _followSymlinks) {
897
931
  subpath = subpath.replaceAll("\\", "/").replace(/\/\/+/g, "/");
898
932
 
933
+ if (subpath === "") {
934
+ throw new FSError("no-entry");
935
+ }
936
+
899
937
  if (subpath.startsWith("/")) {
900
938
  throw new FSError("not-permitted");
901
939
  }
@@ -924,6 +962,59 @@ class Descriptor {
924
962
  return `${baseNormalized}/${segments.join("/")}`;
925
963
  }
926
964
 
965
+ async #statForIdentity() {
966
+ if (this.#hostPreopen) {
967
+ return fs.stat(this.#hostPreopen, { bigint: true });
968
+ }
969
+ this.#ensureHandle();
970
+ return this.#handle.stat({ bigint: true });
971
+ }
972
+
973
+ // WASI paths are always relative to a directory descriptor. During path
974
+ // resolution, both `..` and symlinks must not escape that descriptor's base.
975
+ async #ensureSandboxedPath(subpath) {
976
+ if (!this.#isDirectory) {
977
+ throw new FSError("not-directory");
978
+ }
979
+
980
+ const base = this.#hostPreopen ?? this.#fullPath;
981
+ const baseResolved = nodePath.resolve(base);
982
+ const segments = subpath.replaceAll("\\", "/").replace(/\/\/+/g, "/").split("/");
983
+ let current = baseResolved;
984
+
985
+ for (const seg of segments) {
986
+ if (seg === "" || seg === ".") {
987
+ continue;
988
+ }
989
+ if (seg === "..") {
990
+ current = nodePath.dirname(current);
991
+ if (!isWithinPath(baseResolved, current)) {
992
+ throw new FSError("not-permitted");
993
+ }
994
+ continue;
995
+ }
996
+
997
+ current = nodePath.join(current, seg);
998
+ let stat;
999
+ try {
1000
+ stat = await fs.lstat(current);
1001
+ } catch (err) {
1002
+ if (err.code === "ENOENT") {
1003
+ return;
1004
+ }
1005
+ throw FSError.from(err);
1006
+ }
1007
+
1008
+ if (stat.isSymbolicLink()) {
1009
+ const target = await fs.readlink(current);
1010
+ current = nodePath.resolve(nodePath.dirname(current), target);
1011
+ if (!isWithinPath(baseResolved, current)) {
1012
+ throw new FSError("not-permitted");
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+
927
1018
  #ensureHandle() {
928
1019
  if (!this.#handle) {
929
1020
  throw new FSError("bad-descriptor");
@@ -964,6 +1055,11 @@ function stripTrailingSlash(path) {
964
1055
  return path.replace(/\/+$/, "");
965
1056
  }
966
1057
 
1058
+ function isWithinPath(base, candidate) {
1059
+ const relative = nodePath.relative(base, candidate);
1060
+ return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
1061
+ }
1062
+
967
1063
  const preopenEntries = [];
968
1064
  export const preopens = {
969
1065
  Descriptor,
@@ -115,16 +115,19 @@ export class Fields {
115
115
  let bucket = this.#table.get(lowercased);
116
116
  if (bucket) {
117
117
  this.#entries = this.#entries.filter((e) => !bucket.includes(e));
118
- bucket.splice(0, bucket.length);
119
- } else {
120
- bucket = [];
121
- this.#table.set(lowercased, bucket);
118
+ this.#table.delete(lowercased);
119
+ }
120
+
121
+ if (values.length === 0) {
122
+ return;
122
123
  }
123
124
 
125
+ bucket = [];
126
+ this.#table.set(lowercased, bucket);
124
127
  for (const value of values) {
125
128
  const entry = [name, value];
126
129
  this.#entries.push(entry);
127
- this.#table.get(lowercased).push(entry);
130
+ bucket.push(entry);
128
131
  }
129
132
  }
130
133
 
@@ -140,6 +143,7 @@ export class Fields {
140
143
  */
141
144
  delete(name) {
142
145
  this.#ensureMutable();
146
+ this.#validateName(name);
143
147
  const lowercased = name.toLowerCase();
144
148
  const tableEntries = this.#table.get(lowercased);
145
149
 
@@ -162,6 +166,7 @@ export class Fields {
162
166
  */
163
167
  getAndDelete(name) {
164
168
  this.#ensureMutable();
169
+ this.#validateName(name);
165
170
 
166
171
  const values = this.get(name);
167
172
  this.delete(name);
@@ -188,10 +193,11 @@ export class Fields {
188
193
  this.#validateValue(name, value);
189
194
 
190
195
  const lowercased = name.toLowerCase();
191
- const entry = [name, value];
192
- this.#entries.push(entry);
193
196
 
194
197
  const tableEntries = this.#table.get(lowercased);
198
+ const entryName = tableEntries?.[0]?.[0] ?? name;
199
+ const entry = [entryName, value];
200
+ this.#entries.push(entry);
195
201
  if (tableEntries) {
196
202
  tableEntries.push(entry);
197
203
  } else {
@@ -274,7 +280,7 @@ export class Fields {
274
280
 
275
281
  #validateValue(name, value) {
276
282
  try {
277
- validateHeaderValue(name, new TextDecoder().decode(value));
283
+ validateHeaderValue(name, bytesToHeaderValueString(value));
278
284
  } catch {
279
285
  throw new HttpError("invalid-syntax", `Invalid header value for ${name}`);
280
286
  }
@@ -287,6 +293,14 @@ export class Fields {
287
293
  }
288
294
  }
289
295
 
296
+ function bytesToHeaderValueString(value) {
297
+ let result = "";
298
+ for (let i = 0; i < value.length; i += 0x8000) {
299
+ result += String.fromCharCode(...value.subarray(i, i + 0x8000));
300
+ }
301
+ return result;
302
+ }
303
+
290
304
  export function _fieldsLock(fields) {
291
305
  return Fields._lock(fields);
292
306
  }
@@ -325,7 +325,7 @@ export class Request {
325
325
  */
326
326
  setPathWithQuery(pathWithQuery) {
327
327
  validateUrlPart(pathWithQuery, UrlPart.PATH_WITH_QUERY);
328
- this.#pathWithQuery = pathWithQuery ?? undefined;
328
+ this.#pathWithQuery = pathWithQuery === "" ? "/" : (pathWithQuery ?? undefined);
329
329
  }
330
330
 
331
331
  /**
@@ -575,7 +575,7 @@ const UrlPart = {
575
575
  };
576
576
 
577
577
  function normalizeMethod(method) {
578
- const VALUE_TOKEN_RE = /^[a-zA-Z-]+$/;
578
+ const VALUE_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
579
579
 
580
580
  if (typeof method === "string") {
581
581
  if (!VALUE_TOKEN_RE.test(method)) {
@@ -597,7 +597,10 @@ function normalizeMethod(method) {
597
597
  }
598
598
 
599
599
  if (method.tag === "other" && typeof method.val === "string" && VALUE_TOKEN_RE.test(method.val)) {
600
- return { tag: "other", val: method.val.toLowerCase() };
600
+ const standard = method.val.toLowerCase();
601
+ return SUPPORTED_METHODS.includes(standard) && method.val === method.val.toUpperCase()
602
+ ? { tag: standard }
603
+ : { tag: "other", val: method.val };
601
604
  }
602
605
 
603
606
  throw new HttpError("invalid-syntax");
@@ -614,7 +617,7 @@ function normalizeScheme(scheme) {
614
617
  if (uppercase === "HTTP" || uppercase === "HTTPS") {
615
618
  return { tag: uppercase };
616
619
  }
617
- return { tag: "other", val: scheme.toLowerCase() };
620
+ return { tag: "other", val: scheme };
618
621
  }
619
622
 
620
623
  if (typeof scheme !== "object" || typeof scheme.tag !== "string") {
@@ -627,7 +630,11 @@ function normalizeScheme(scheme) {
627
630
 
628
631
  if (scheme.tag === "other" && typeof scheme.val === "string") {
629
632
  validateUrlPart(scheme.val, UrlPart.SCHEME);
630
- return { tag: "other", val: scheme.val.toLowerCase() };
633
+ const uppercase = scheme.val.toUpperCase();
634
+ if (uppercase === "HTTP" || uppercase === "HTTPS") {
635
+ return { tag: uppercase };
636
+ }
637
+ return { tag: "other", val: scheme.val };
631
638
  }
632
639
 
633
640
  throw new HttpError("invalid-syntax");
@@ -660,6 +667,10 @@ function validateUrlPart(value, part) {
660
667
  throw new HttpError("invalid-syntax", `Invalid ${part}: ${value}`);
661
668
  }
662
669
 
670
+ if (part === UrlPart.PATH_WITH_QUERY && /[ <>`]/.test(value)) {
671
+ throw new HttpError("invalid-syntax", `Invalid ${part}: ${value}`);
672
+ }
673
+
663
674
  try {
664
675
  switch (part) {
665
676
  case UrlPart.PATH_WITH_QUERY:
@@ -23,6 +23,16 @@ function token() {
23
23
  return (TCP_CREATE_TOKEN ??= Symbol("TcpCreateToken"));
24
24
  }
25
25
 
26
+ function invalidRecv() {
27
+ const stream = new ReadableStream({
28
+ start(controller) {
29
+ controller.close();
30
+ },
31
+ });
32
+ const promise = Promise.reject(new SocketError("invalid-state"));
33
+ return [new StreamReader(stream, { preventCancel: false }), new FutureReader(promise)];
34
+ }
35
+
26
36
  const STATE = {
27
37
  UNBOUND: "unbound",
28
38
  BOUND: "bound",
@@ -316,16 +326,10 @@ export class TcpSocket {
316
326
  */
317
327
  receive() {
318
328
  if (this.#state !== STATE.CONNECTED) {
319
- throw new SocketError("invalid-state");
329
+ return invalidRecv();
320
330
  }
321
331
  if (this.#receiveStarted) {
322
- const stream = new ReadableStream({
323
- start(controller) {
324
- controller.close();
325
- },
326
- });
327
- const promise = Promise.reject(new SocketError("invalid-state"));
328
- return [new StreamReader(stream, { preventCancel: false }), new FutureReader(promise)];
332
+ return invalidRecv();
329
333
  }
330
334
  this.#receiveStarted = true;
331
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytecodealliance/preview3-shim",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "WASI Preview3 shim for JS environments",
5
5
  "homepage": "https://github.com/bytecodealliance/jco/blob/main/packages/preview3-shim/#readme",
6
6
  "bugs": {
@@ -43,7 +43,7 @@
43
43
  "devDependencies": {
44
44
  "globals": "^16.3.0",
45
45
  "vite": "^7.1.5",
46
- "vitest": "^3.2.4"
46
+ "vitest": "^4.1.0"
47
47
  },
48
48
  "scripts": {
49
49
  "compile:check": "tsc --noEmit types/index.d.ts",