@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 +15 -0
- package/package.json +1 -1
- package/src/acquire.test.ts +18 -1
- package/src/patch.ts +92 -8
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
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" +
|
|
@@ -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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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 (
|
|
1244
|
+
if (edited !== src && before.equals(await readFile(table)))
|
|
1161
1245
|
throw new Error(`Porffor precompile did not update ${table}`);
|
|
1162
1246
|
}
|
|
1163
1247
|
|