@sproutboat/toolchain 0.4.21 → 0.4.23

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @sproutboat/toolchain
2
2
 
3
+ ## 0.4.23
4
+
5
+ ### Patch Changes
6
+
7
+ - a479611: Typed-array writes outside the array are ignored, as the spec requires, instead of overwriting the next allocation in memory (baronunread/sproutboat#241). `a[length] = x`, `a[-1] = x` (which wrote `a[0]`) and `a[1.5] = x` no longer touch any memory. The check uses the same index validation as reads and applies to user code; Porffor's own builtins write within bounds.
8
+
9
+ ## 0.4.22
10
+
11
+ ### Patch Changes
12
+
13
+ - 184fa15: Two more spec fixes for uuid (baronunread/sproutboat#238):
14
+ - **Unary `~`** now applies ToInt32 (wrapping modulo 2^32) instead of a saturating conversion. `~x` for `x >= 2^31`, the `~~x` truncation idiom and `~Infinity` were all wrong, which broke uuid's SHA-1.
15
+ - **Typed-array reads** with an index outside the array, or a negative or fractional index, return `undefined` instead of reading neighbouring memory. uuid's v5 UUIDs used to change from run to run because its SHA-1 reads past its buffer. The check applies to user code; Porffor's own precompiled builtins keep their reads.
16
+ - d03abed: `TypedArray.prototype.set(source)` without an offset copies to index 0 (part of baronunread/sproutboat#238). Porffor computed the offset as `Math.trunc(undefined)`, which is NaN, so a same-type copy wrote nothing where it should and went to the wrong place in memory instead. This dropped HMAC keys in @noble/hashes and killed the process after the first request. The offset now goes through `ToIntegerOrInfinity` as the spec says. @noble/hashes' SHA-256 and HMAC match Node and keep serving.
17
+
3
18
  ## 0.4.21
4
19
 
5
20
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/toolchain",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -40,6 +40,7 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
40
40
  "compiler/builtins_precompiled.js": "fixture:precompiled\n",
41
41
  "compiler/builtins/date.ts": "export const __ecma262_ParseDTSF = (string: string) => {\n let n: number = 0;\n let nInd: number = 0;\n\n const len: i32 = string.length;\n const endPtr: i32 = Porffor.IR.ptr(string) + len;\n let ptr: i32 = Porffor.IR.ptr(string);\n\n while (ptr <= endPtr) { // <= to include extra null byte to set last n\n const chr: i32 = Porffor.IR.loadU8(ptr++, 4);\n if (Porffor.fastAnd(chr >= 48, chr <= 57)) { // 0-9\n n *= 10;\n n += chr - 48;\n continue;\n }\n\n if (chr == 45) { // -\n if (Porffor.fastOr(ptr == Porffor.IR.ptr(string), nInd == 7)) n = -n;\n }\n\n if (n > 0) {\n if (nInd == 0) y = n;\n else if (nInd == 1) m = n - 1;\n else if (nInd == 2) dt = n;\n else if (nInd == 3) h = n;\n else if (nInd == 4) min = n;\n else if (nInd == 5) s = n;\n else if (nInd == 6) milli = n;\n else if (nInd == 7) tzHour = n;\n else if (nInd == 8) tzMin = n;\n\n n = 0;\n nInd++;\n }\n }\n\n h += tzHour;\n min += tzMin;\n};\n// RFC 7231 or Date.prototype.toString() parser\n",
42
42
  "compiler/builtins/typedarray.js":
43
+ " offset = Math.trunc(offset);\n if (Porffor.fastOr(offset < 0, offset > len)) throw new RangeError('Offset out of bounds');\n" +
43
44
  "export const __${name}_from = (arg: any, mapFn: any): ${name} => {\n" +
44
45
  " const arr: any[] = Porffor.array.new(4);\n" +
45
46
  " let len: i32 = 0;\n" +
@@ -121,7 +122,13 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
121
122
  "compiler/builtins/json.ts": "// sb_json_utf16_v1\n",
122
123
  "runtime/fetch-globals.js": "// sb_text_encoder_scalar_v1\n",
123
124
  "compiler/codegen.js":
124
- " stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, f) : Convert(T.u32, f, 0)));\n",
125
+ " stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, f) : Convert(T.u32, f, 0)));\n" +
126
+ " return Box(Convert(T.f64, Un('~', T.i32, Convert(T.i32, numValue(toNumeric())))), Const(T.i32, TYPES.number));\n" +
127
+ " const taGet = (ctype, size, signed = true) => () => {\n" +
128
+ " const loaded = Load(ctype, taAddr(size), 4);\n" +
129
+ " const f = ctype === 'f32' || ctype === 'f64' ? loaded : Convert(T.f64, loaded, signed ? CONVERT_SIGNED : 0);\n" +
130
+ " return Box(f, Const(T.i32, TYPES.number));\n" +
131
+ " };\n",
125
132
  "compiler/builtins/string.ts":
126
133
  "export const __Porffor_string_replaceAll = (str: any, searchValue: any, replaceValue: any) => {\n" +
127
134
  " let out: any = __Porffor_string_emptyLike(str);\n let appendIndex: i32 = 0;\n let searchIndex: i32 = 0;\n let matched: boolean = false;\n" +
@@ -210,6 +217,16 @@ test("the default call (no url/expectedSha256 override) never touches the networ
210
217
  },
211
218
  });
212
219
  expect(await readFile(join(dir, "runtime/index.js"), "utf8")).not.toBe("");
220
+ // #238: TypedArray.prototype.set(source) with no offset copies to index 0.
221
+ expect(await readFile(join(dir, "compiler/builtins/typedarray.js"), "utf8")).toContain(
222
+ "offset = ecma262.ToIntegerOrInfinity(offset);",
223
+ );
224
+ // #238: ~ wraps (ToInt32), and out-of-range typed-array reads are undefined.
225
+ const codegen = await readFile(join(dir, "compiler/codegen.js"), "utf8");
226
+ expect(codegen).toContain("Convert(T.i32, toUint32(scope, numValue(toNumeric())), CONVERT_RANGE_KNOWN | CONVERT_SIGNED)");
227
+ expect(codegen).toContain("Box(f, Const(T.i32, TYPES.number)), valUndefined());");
228
+ // #241: out-of-range typed-array writes are ignored in user code.
229
+ expect(codegen).toContain("if (globalThis.precompile) store();");
213
230
  // #238: integer typed-array stores wrap modulo 2^n instead of saturating.
214
231
  expect(await readFile(join(dir, "compiler/codegen.js"), "utf8")).toContain(
215
232
  "signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)",
package/src/patch.ts CHANGED
@@ -305,6 +305,75 @@ const TA_STORE_INJECT =
305
305
  " // sproutboat #238: typed-array stores wrap modulo 2^n (ToUint32), not saturate.\n" +
306
306
  " stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)));";
307
307
  const TA_STORE_MARKER = "sproutboat #238: typed-array stores wrap";
308
+ // baronunread/sproutboat#238: unary `~` had the same saturating conversion, so
309
+ // ~x for x >= 2^31 (or ~~x, the common truncation idiom) clamped to
310
+ // +/-2147483648, and ~Infinity gave -2147483648 instead of -1. uuid's SHA-1
311
+ // computes `x & y ^ ~x & z` on words above 2^31, so every v5 UUID was wrong.
312
+ const BITNOT_ANCHOR =
313
+ " return Box(Convert(T.f64, Un('~', T.i32, Convert(T.i32, numValue(toNumeric())))), Const(T.i32, TYPES.number));";
314
+ const BITNOT_INJECT =
315
+ " // sproutboat #238: ~ applies ToInt32 (wrap modulo 2^32), not a saturating cast.\n" +
316
+ " return Box(Convert(T.f64, Un('~', T.i32, Convert(T.i32, toUint32(scope, numValue(toNumeric())), CONVERT_RANGE_KNOWN | CONVERT_SIGNED))), Const(T.i32, TYPES.number));";
317
+ const BITNOT_MARKER = "sproutboat #238: ~ applies ToInt32";
318
+ // baronunread/sproutboat#238: typed-array element reads and writes had no
319
+ // bounds check. A read past the end returned whatever sat in the next
320
+ // allocation instead of undefined (uuid's SHA-1 reads past its buffer, so v5
321
+ // UUIDs changed run to run) and a[-1] / a[1.5] broke outright. Reuse the
322
+ // plain-array index check and compare against the typed array's length.
323
+ // Builtins keep the unchecked read: they index within bounds, and the check
324
+ // broke typed-array join/toString when compiled into them. Writes need the same
325
+ // guard, but builtins such as the constructor and fill() store elements before
326
+ // they set the length, so that is left for a follow-up.
327
+ const TA_GET_ANCHOR =
328
+ " const taGet = (ctype, size, signed = true) => () => {\n" +
329
+ " const loaded = Load(ctype, taAddr(size), 4);\n" +
330
+ " const f = ctype === 'f32' || ctype === 'f64' ? loaded : Convert(T.f64, loaded, signed ? CONVERT_SIGNED : 0);\n" +
331
+ " return Box(f, Const(T.i32, TYPES.number));\n" +
332
+ " };";
333
+ const TA_GET_INJECT =
334
+ " const taGet = (ctype, size, signed = true) => () => {\n" +
335
+ " // sproutboat #238: only a valid in-range integer index reads; anything else is undefined.\n" +
336
+ " // User code only: builtins (precompiled) index within bounds themselves and\n" +
337
+ " // typed-array join/toString misbehave with the extra check.\n" +
338
+ " if (globalThis.precompile) {\n" +
339
+ " const raw = Load(ctype, taAddr(size), 4);\n" +
340
+ " return Box(ctype === 'f32' || ctype === 'f64' ? raw : Convert(T.f64, raw, signed ? CONVERT_SIGNED : 0), Const(T.i32, TYPES.number));\n" +
341
+ " }\n" +
342
+ " const { idx, valid } = denseArrayIndexKey(scope, prop);\n" +
343
+ " const loaded = Load(ctype, Bin('+', T.u32, Load('u32', JvPtr(obj), 4), size === 1 ? idx : Bin('*', T.u32, idx, Const(T.u32, size))), 4);\n" +
344
+ " const f = ctype === 'f32' || ctype === 'f64' ? loaded : Convert(T.f64, loaded, signed ? CONVERT_SIGNED : 0);\n" +
345
+ " return Select(Bin('&&', T.i32, valid, Bin('<', T.i32, idx, Load('u32', JvPtr(obj), 0))),\n" +
346
+ " Box(f, Const(T.i32, TYPES.number)), valUndefined());\n" +
347
+ " };";
348
+ const TA_GET_MARKER = "sproutboat #238: only a valid in-range integer index reads";
349
+ // baronunread/sproutboat#241: a typed-array write outside the array stored into
350
+ // whatever memory followed it (and a[-1] = x wrote a[0], via the saturating
351
+ // index conversion). ECMA-262 ignores the write. Same check as taGet, in user
352
+ // code only: builtins such as the constructor store elements before they set
353
+ // the length, and they only write in bounds anyway. Applies after TA_STORE,
354
+ // whose output line is the anchor.
355
+ const TA_SET_BOUNDS_ANCHOR =
356
+ " stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)));";
357
+ const TA_SET_BOUNDS_INJECT =
358
+ " const store = () => stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)));\n" +
359
+ " // sproutboat #241: a write outside the array is ignored, not stored into the next allocation.\n" +
360
+ " if (globalThis.precompile) store();\n" +
361
+ " else {\n" +
362
+ " const { idx: sbIdx, valid: sbValid } = denseArrayIndexKey(scope, prop);\n" +
363
+ " emitIf(scope, Bin('&&', T.i32, sbValid, Bin('<', T.i32, sbIdx, Load('u32', JvPtr(obj), 0))), store);\n" +
364
+ " }";
365
+ const TA_SET_BOUNDS_MARKER = "sproutboat #241: a write outside the array is ignored";
366
+
367
+ // baronunread/sproutboat#238: TypedArray.prototype.set(source) with no offset
368
+ // ran `offset = Math.trunc(undefined)`, which is NaN. The same-type fast path
369
+ // then copied to `base + NaN * BYTES_PER_ELEMENT`: nothing landed where it
370
+ // should (noble's HMAC lost its key in `pad.set(key)`), and the write went
371
+ // somewhere else in memory. ECMA-262 uses ToIntegerOrInfinity, which maps
372
+ // undefined and NaN to 0; subarray right below already does.
373
+ const TA_SET_OFFSET_ANCHOR = " offset = Math.trunc(offset);\n if (Porffor.fastOr(offset < 0, offset > len)) throw new RangeError('Offset out of bounds');";
374
+ const TA_SET_OFFSET_INJECT =
375
+ " offset = ecma262.ToIntegerOrInfinity(offset); // sproutboat #238: undefined offset is 0, not NaN\n if (Porffor.fastOr(offset < 0, offset > len)) throw new RangeError('Offset out of bounds');";
376
+ const TA_SET_OFFSET_MARKER = "sproutboat #238: undefined offset is 0";
308
377
 
309
378
  // Porffor's TypedArray.from only handles iterables. An array-like input such
310
379
  // as { length: 16 } silently becomes an empty typed array, including HMAC
@@ -1126,11 +1195,20 @@ export async function patchReplaceAll(root: string): Promise<void> {
1126
1195
  /** #238: modular typed-array stores. Before the precompile so builtins get it too. */
1127
1196
  export async function patchTypedArrayStore(root: string): Promise<void> {
1128
1197
  const file = resolve(root, "compiler/codegen.js");
1129
- const src = await readFile(file, "utf8");
1130
- if (src.includes(TA_STORE_MARKER)) return;
1131
- if (!src.includes(TA_STORE_ANCHOR))
1132
- throw new Error(`could not patch Porffor's typed-array store conversion: anchor not found in ${file}`);
1133
- await writeFile(file, src.replace(TA_STORE_ANCHOR, TA_STORE_INJECT));
1198
+ let src = await readFile(file, "utf8");
1199
+ let changed = false;
1200
+ for (const [marker, anchor, inject, what] of [
1201
+ [TA_STORE_MARKER, TA_STORE_ANCHOR, TA_STORE_INJECT, "typed-array store conversion"],
1202
+ [BITNOT_MARKER, BITNOT_ANCHOR, BITNOT_INJECT, "bitwise NOT conversion"],
1203
+ [TA_GET_MARKER, TA_GET_ANCHOR, TA_GET_INJECT, "typed-array read bounds"],
1204
+ [TA_SET_BOUNDS_MARKER, TA_SET_BOUNDS_ANCHOR, TA_SET_BOUNDS_INJECT, "typed-array write bounds"],
1205
+ ] as const) {
1206
+ if (src.includes(marker)) continue;
1207
+ if (!src.includes(anchor)) throw new Error(`could not patch Porffor's ${what}: anchor not found in ${file}`);
1208
+ src = src.replace(anchor, inject);
1209
+ changed = true;
1210
+ }
1211
+ if (changed) await writeFile(file, src);
1134
1212
  }
1135
1213
 
1136
1214
  export async function patchTypedArrayFrom(root: string): Promise<void> {
@@ -1139,8 +1217,14 @@ export async function patchTypedArrayFrom(root: string): Promise<void> {
1139
1217
  if (!src.includes(TYPED_ARRAY_FROM_MARKER) && !src.includes(TYPED_ARRAY_FROM_ANCHOR)) {
1140
1218
  throw new Error(`could not patch Porffor's TypedArray.from: anchor not found in ${file}`);
1141
1219
  }
1142
- if (!src.includes(TYPED_ARRAY_FROM_MARKER))
1143
- await writeFile(file, src.replace(TYPED_ARRAY_FROM_ANCHOR, TYPED_ARRAY_FROM_INJECT));
1220
+ let edited = src;
1221
+ if (!edited.includes(TYPED_ARRAY_FROM_MARKER)) edited = edited.replace(TYPED_ARRAY_FROM_ANCHOR, TYPED_ARRAY_FROM_INJECT);
1222
+ if (!edited.includes(TA_SET_OFFSET_MARKER)) {
1223
+ if (!edited.includes(TA_SET_OFFSET_ANCHOR))
1224
+ throw new Error(`could not patch Porffor's TypedArray.prototype.set offset: anchor not found in ${file}`);
1225
+ edited = edited.replace(TA_SET_OFFSET_ANCHOR, TA_SET_OFFSET_INJECT);
1226
+ }
1227
+ if (edited !== src) await writeFile(file, edited);
1144
1228
  const table = resolve(root, "compiler/builtins_precompiled.js");
1145
1229
  const before = await readFile(table);
1146
1230
  // Porffor only writes the table when import.meta.url matches argv[1].
@@ -1157,7 +1241,7 @@ export async function patchTypedArrayFrom(root: string): Promise<void> {
1157
1241
  new Response(child.stderr).text(),
1158
1242
  ]);
1159
1243
  if (code !== 0) throw new Error(`could not precompile Porffor's TypedArray.from: ${stderr || stdout}`);
1160
- if (!src.includes(TYPED_ARRAY_FROM_MARKER) && before.equals(await readFile(table)))
1244
+ if (edited !== src && before.equals(await readFile(table)))
1161
1245
  throw new Error(`Porffor precompile did not update ${table}`);
1162
1246
  }
1163
1247