@sproutboat/toolchain 0.4.21 → 0.4.22

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,14 @@
1
1
  # @sproutboat/toolchain
2
2
 
3
+ ## 0.4.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 184fa15: Two more spec fixes for uuid (baronunread/sproutboat#238):
8
+ - **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.
9
+ - **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.
10
+ - 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.
11
+
3
12
  ## 0.4.21
4
13
 
5
14
  ### 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.22",
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,14 @@ 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());");
213
228
  // #238: integer typed-array stores wrap modulo 2^n instead of saturating.
214
229
  expect(await readFile(join(dir, "compiler/codegen.js"), "utf8")).toContain(
215
230
  "signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)",
package/src/patch.ts CHANGED
@@ -305,6 +305,58 @@ 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
+
350
+ // baronunread/sproutboat#238: TypedArray.prototype.set(source) with no offset
351
+ // ran `offset = Math.trunc(undefined)`, which is NaN. The same-type fast path
352
+ // then copied to `base + NaN * BYTES_PER_ELEMENT`: nothing landed where it
353
+ // should (noble's HMAC lost its key in `pad.set(key)`), and the write went
354
+ // somewhere else in memory. ECMA-262 uses ToIntegerOrInfinity, which maps
355
+ // undefined and NaN to 0; subarray right below already does.
356
+ const TA_SET_OFFSET_ANCHOR = " offset = Math.trunc(offset);\n if (Porffor.fastOr(offset < 0, offset > len)) throw new RangeError('Offset out of bounds');";
357
+ const TA_SET_OFFSET_INJECT =
358
+ " 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');";
359
+ const TA_SET_OFFSET_MARKER = "sproutboat #238: undefined offset is 0";
308
360
 
309
361
  // Porffor's TypedArray.from only handles iterables. An array-like input such
310
362
  // as { length: 16 } silently becomes an empty typed array, including HMAC
@@ -1126,11 +1178,19 @@ export async function patchReplaceAll(root: string): Promise<void> {
1126
1178
  /** #238: modular typed-array stores. Before the precompile so builtins get it too. */
1127
1179
  export async function patchTypedArrayStore(root: string): Promise<void> {
1128
1180
  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));
1181
+ let src = await readFile(file, "utf8");
1182
+ let changed = false;
1183
+ for (const [marker, anchor, inject, what] of [
1184
+ [TA_STORE_MARKER, TA_STORE_ANCHOR, TA_STORE_INJECT, "typed-array store conversion"],
1185
+ [BITNOT_MARKER, BITNOT_ANCHOR, BITNOT_INJECT, "bitwise NOT conversion"],
1186
+ [TA_GET_MARKER, TA_GET_ANCHOR, TA_GET_INJECT, "typed-array read bounds"],
1187
+ ] as const) {
1188
+ if (src.includes(marker)) continue;
1189
+ if (!src.includes(anchor)) throw new Error(`could not patch Porffor's ${what}: anchor not found in ${file}`);
1190
+ src = src.replace(anchor, inject);
1191
+ changed = true;
1192
+ }
1193
+ if (changed) await writeFile(file, src);
1134
1194
  }
1135
1195
 
1136
1196
  export async function patchTypedArrayFrom(root: string): Promise<void> {
@@ -1139,8 +1199,14 @@ export async function patchTypedArrayFrom(root: string): Promise<void> {
1139
1199
  if (!src.includes(TYPED_ARRAY_FROM_MARKER) && !src.includes(TYPED_ARRAY_FROM_ANCHOR)) {
1140
1200
  throw new Error(`could not patch Porffor's TypedArray.from: anchor not found in ${file}`);
1141
1201
  }
1142
- if (!src.includes(TYPED_ARRAY_FROM_MARKER))
1143
- await writeFile(file, src.replace(TYPED_ARRAY_FROM_ANCHOR, TYPED_ARRAY_FROM_INJECT));
1202
+ let edited = src;
1203
+ if (!edited.includes(TYPED_ARRAY_FROM_MARKER)) edited = edited.replace(TYPED_ARRAY_FROM_ANCHOR, TYPED_ARRAY_FROM_INJECT);
1204
+ if (!edited.includes(TA_SET_OFFSET_MARKER)) {
1205
+ if (!edited.includes(TA_SET_OFFSET_ANCHOR))
1206
+ throw new Error(`could not patch Porffor's TypedArray.prototype.set offset: anchor not found in ${file}`);
1207
+ edited = edited.replace(TA_SET_OFFSET_ANCHOR, TA_SET_OFFSET_INJECT);
1208
+ }
1209
+ if (edited !== src) await writeFile(file, edited);
1144
1210
  const table = resolve(root, "compiler/builtins_precompiled.js");
1145
1211
  const before = await readFile(table);
1146
1212
  // Porffor only writes the table when import.meta.url matches argv[1].
@@ -1157,7 +1223,7 @@ export async function patchTypedArrayFrom(root: string): Promise<void> {
1157
1223
  new Response(child.stderr).text(),
1158
1224
  ]);
1159
1225
  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)))
1226
+ if (edited !== src && before.equals(await readFile(table)))
1161
1227
  throw new Error(`Porffor precompile did not update ${table}`);
1162
1228
  }
1163
1229