@sproutboat/toolchain 0.4.20 → 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 +15 -0
- package/package.json +1 -1
- package/src/acquire.test.ts +21 -0
- package/src/patch.ts +95 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
12
|
+
## 0.4.21
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 1b80c4b: Integer typed-array stores wrap modulo 2^n as the spec requires, instead of saturating (part of baronunread/sproutboat#238). Porffor converted the stored value with a saturating float-to-int conversion, so `uint32[i] = x | 0` stored 0 for any negative `x`, `int32[i] = 4e9` stored 2147483647, and `fill`/`set`/`from` had the same bug. This broke pure-JS hashing such as @noble/hashes' SHA-256, which now matches Node.
|
|
17
|
+
|
|
3
18
|
## 0.4.20
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/package.json
CHANGED
package/src/acquire.test.ts
CHANGED
|
@@ -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" +
|
|
@@ -120,6 +121,14 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
|
|
|
120
121
|
"}\n",
|
|
121
122
|
"compiler/builtins/json.ts": "// sb_json_utf16_v1\n",
|
|
122
123
|
"runtime/fetch-globals.js": "// sb_text_encoder_scalar_v1\n",
|
|
124
|
+
"compiler/codegen.js":
|
|
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",
|
|
123
132
|
"compiler/builtins/string.ts":
|
|
124
133
|
"export const __Porffor_string_replaceAll = (str: any, searchValue: any, replaceValue: any) => {\n" +
|
|
125
134
|
" let out: any = __Porffor_string_emptyLike(str);\n let appendIndex: i32 = 0;\n let searchIndex: i32 = 0;\n let matched: boolean = false;\n" +
|
|
@@ -208,6 +217,18 @@ test("the default call (no url/expectedSha256 override) never touches the networ
|
|
|
208
217
|
},
|
|
209
218
|
});
|
|
210
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
|
+
// #238: integer typed-array stores wrap modulo 2^n instead of saturating.
|
|
229
|
+
expect(await readFile(join(dir, "compiler/codegen.js"), "utf8")).toContain(
|
|
230
|
+
"signed ? Convert(T.i32, toUint32(scope, f), CONVERT_RANGE_KNOWN | CONVERT_SIGNED) : toUint32(scope, f)",
|
|
231
|
+
);
|
|
211
232
|
// #237: replaceAll joins its pieces once instead of a strcat per match.
|
|
212
233
|
expect(await readFile(join(dir, "compiler/builtins/string.ts"), "utf8")).toContain(
|
|
213
234
|
"return Porffor.callThis(__Array_prototype_join, pieces, '');",
|
package/src/patch.ts
CHANGED
|
@@ -291,6 +291,73 @@ const REPLACE_ALL_EDITS = [
|
|
|
291
291
|
] as const;
|
|
292
292
|
const REPLACE_ALL_MARKER = "sproutboat #237: pieces joined once";
|
|
293
293
|
|
|
294
|
+
// baronunread/sproutboat#238: an integer typed-array element store converted
|
|
295
|
+
// its value with a saturating f64 -> int Convert, so a negative value stored
|
|
296
|
+
// into a Uint32Array became 0 and 4e9 into an Int32Array became 2147483647.
|
|
297
|
+
// ECMA-262 (IntegerIndexedElementSet -> ToUint32 / ToInt32 / ToUint16 ...) wraps
|
|
298
|
+
// modulo 2^n instead. noble-hashes' SHA-256 stores `x | 0` into a Uint32Array,
|
|
299
|
+
// so every negative schedule word was zeroed and the digest came out wrong.
|
|
300
|
+
// Route the store through codegen's own modular toUint32 (what bitwise ops use);
|
|
301
|
+
// narrower stores keep the low bits, which is exactly the modular result.
|
|
302
|
+
const TA_STORE_ANCHOR =
|
|
303
|
+
" stmt(scope, Store(ctype, addr, 4, ctype === 'f64' || ctype === 'f32' ? f : signed ? Convert(T.i32, f) : Convert(T.u32, f, 0)));";
|
|
304
|
+
const TA_STORE_INJECT =
|
|
305
|
+
" // sproutboat #238: typed-array stores wrap modulo 2^n (ToUint32), not saturate.\n" +
|
|
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
|
+
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";
|
|
360
|
+
|
|
294
361
|
// Porffor's TypedArray.from only handles iterables. An array-like input such
|
|
295
362
|
// as { length: 16 } silently becomes an empty typed array, including HMAC
|
|
296
363
|
// keys made with Uint8Array.from({ length: 16 }, mapFn). The compiler uses a
|
|
@@ -1108,14 +1175,38 @@ export async function patchReplaceAll(root: string): Promise<void> {
|
|
|
1108
1175
|
await writeFile(file, src);
|
|
1109
1176
|
}
|
|
1110
1177
|
|
|
1178
|
+
/** #238: modular typed-array stores. Before the precompile so builtins get it too. */
|
|
1179
|
+
export async function patchTypedArrayStore(root: string): Promise<void> {
|
|
1180
|
+
const file = resolve(root, "compiler/codegen.js");
|
|
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);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1111
1196
|
export async function patchTypedArrayFrom(root: string): Promise<void> {
|
|
1112
1197
|
const file = resolve(root, "compiler/builtins/typedarray.js");
|
|
1113
1198
|
const src = await readFile(file, "utf8");
|
|
1114
1199
|
if (!src.includes(TYPED_ARRAY_FROM_MARKER) && !src.includes(TYPED_ARRAY_FROM_ANCHOR)) {
|
|
1115
1200
|
throw new Error(`could not patch Porffor's TypedArray.from: anchor not found in ${file}`);
|
|
1116
1201
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
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);
|
|
1119
1210
|
const table = resolve(root, "compiler/builtins_precompiled.js");
|
|
1120
1211
|
const before = await readFile(table);
|
|
1121
1212
|
// Porffor only writes the table when import.meta.url matches argv[1].
|
|
@@ -1132,7 +1223,7 @@ export async function patchTypedArrayFrom(root: string): Promise<void> {
|
|
|
1132
1223
|
new Response(child.stderr).text(),
|
|
1133
1224
|
]);
|
|
1134
1225
|
if (code !== 0) throw new Error(`could not precompile Porffor's TypedArray.from: ${stderr || stdout}`);
|
|
1135
|
-
if (
|
|
1226
|
+
if (edited !== src && before.equals(await readFile(table)))
|
|
1136
1227
|
throw new Error(`Porffor precompile did not update ${table}`);
|
|
1137
1228
|
}
|
|
1138
1229
|
|
|
@@ -1185,6 +1276,7 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
|
|
|
1185
1276
|
await patchDateParser(root);
|
|
1186
1277
|
await patchPromiseResolveNull(root);
|
|
1187
1278
|
await patchReplaceAll(root);
|
|
1279
|
+
await patchTypedArrayStore(root);
|
|
1188
1280
|
// Last of the builtin edits: its precompile picks up the date, promise and
|
|
1189
1281
|
// replaceAll patches above too.
|
|
1190
1282
|
await patchTypedArrayFrom(root);
|