@sproutboat/runtime 0.6.3 → 0.6.4

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,41 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 8ef3148: Fix (baronunread/sproutboat#180): `__sbToBytes` built its result with repeated
8
+ `s +=` in a loop, the same `O(n^2)` bug class the `__sbFromUtf8` fix in 0.6.3
9
+ addressed, just on the encode side. Porffor's strings have no rope/cons
10
+ optimization, so `+=` in a loop copies the whole accumulated string on every
11
+ append. Both of its branches were affected: the UTF-8 encode loop over string
12
+ input, and the byte-for-byte copy out of an ArrayBuffer or typed array.
13
+
14
+ This one matters because `__sbToBytes` sits behind every `crypto.subtle` call a
15
+ handler makes: `digest`, `importKey`, `sign`, `verify` and the PBKDF2/scrypt
16
+ path all funnel their data, keys, salts and signatures through it. Hashing a
17
+ request body was quadratic in the body's size.
18
+
19
+ `__sbHexOrBytes` had the same shape on a third crypto path (it decodes the
20
+ `expected` side of an HMAC verify) and was worse than a plain `+=` loop: on odd
21
+ iterations it rewrote the last character with `out = out.slice(0, -1) + ...`,
22
+ copying the accumulated string a second time per byte. It now holds the high
23
+ nibble in a local until its pair arrives and pushes one character per byte.
24
+
25
+ Three further accumulators converted to the same array-and-join idiom for
26
+ consistency. These are bounded by header count, id length or parameter count
27
+ rather than by handler input, so they are hygiene rather than a fix:
28
+ `__SproutboatURLSearchParams.prototype.toString()` in the prelude, and `__sbHex`
29
+ plus the outbound-fetch header builder in `transport-embedded.js`.
30
+
31
+ Covered by a new `to-bytes.test.js`, following the `utf8-decode.test.js`
32
+ convention of lifting the pure functions out of the text-only prelude: ASCII,
33
+ 2-byte, 3-byte and surrogate-pair encodes checked byte-identical against
34
+ `TextEncoder`, the existing unpaired-surrogate behaviour pinned, typed-array and
35
+ ArrayBuffer input with high bytes, hex decode in both cases with its non-hex and
36
+ odd-length fallbacks, and long inputs (46KB string, 50,000-byte array, 20,000-byte
37
+ hex) for correctness at the size the quadratic version hurt.
38
+
3
39
  ## 0.6.3
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -171,12 +171,14 @@ class __SproutboatURLSearchParams {
171
171
  return this._keys.length;
172
172
  }
173
173
  toString() {
174
- let out = "";
174
+ // Array + one join("&"), not `out +=` in a loop: see __sbToBytes. A query
175
+ // string is usually short, but a params object built from a large form or
176
+ // a paginated cursor list is not, and the fix costs nothing.
177
+ const out = [];
175
178
  for (let i = 0; i < this._keys.length; i++) {
176
- if (i > 0) out += "&";
177
- out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
179
+ out.push(encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]));
178
180
  }
179
- return out;
181
+ return out.join("&");
180
182
  }
181
183
  }
182
184
 
@@ -295,31 +297,38 @@ function __sbHashBits(algo) {
295
297
  // TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
296
298
  function __sbToBytes(input) {
297
299
  if (input == null) return "";
300
+ // Collected into an array and joined once at the end, not built with
301
+ // repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
302
+ // `+=` in a loop copies the whole accumulated string on every append,
303
+ // making the naive version O(n^2). Same fix as __sbFromUtf8 above, on the
304
+ // encode side (sits behind every crypto.subtle call).
298
305
  if (__sbIsStr(input)) {
299
- let s = "";
306
+ const out = [];
300
307
  for (let i = 0; i < input.length; i++) {
301
308
  const c = input.charCodeAt(i);
302
- if (c < 0x80) s += String.fromCharCode(c);
303
- else if (c < 0x800) s += String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f));
309
+ if (c < 0x80) out.push(String.fromCharCode(c));
310
+ else if (c < 0x800) out.push(String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f)));
304
311
  else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
305
312
  const cp = 0x10000 + ((c - 0xd800) << 10) + (input.charCodeAt(++i) - 0xdc00);
306
- s +=
313
+ out.push(
307
314
  String.fromCharCode(0xf0 | (cp >> 18)) +
308
- String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
309
- String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
310
- String.fromCharCode(0x80 | (cp & 0x3f));
315
+ String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
316
+ String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
317
+ String.fromCharCode(0x80 | (cp & 0x3f))
318
+ );
311
319
  } else
312
- s +=
320
+ out.push(
313
321
  String.fromCharCode(0xe0 | (c >> 12)) +
314
- String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
315
- String.fromCharCode(0x80 | (c & 0x3f));
322
+ String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
323
+ String.fromCharCode(0x80 | (c & 0x3f))
324
+ );
316
325
  }
317
- return s;
326
+ return out.join("");
318
327
  }
319
328
  const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
320
- let s = "";
321
- for (let i = 0; i < view.length; i++) s += String.fromCharCode(view[i] & 0xff);
322
- return s;
329
+ const out = [];
330
+ for (let i = 0; i < view.length; i++) out.push(String.fromCharCode(view[i] & 0xff));
331
+ return out.join("");
323
332
  }
324
333
  function __sbBufFrom(latin1) {
325
334
  const b = new Uint8Array(latin1.length);
@@ -434,15 +443,20 @@ function __sbEqCt(a, b) {
434
443
  // An even-length all-hex string -> its bytes; anything else -> its raw bytes.
435
444
  function __sbHexOrBytes(value) {
436
445
  if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
437
- let out = "";
446
+ // Array-and-join for the same reason as __sbToBytes above; the old version
447
+ // was worse than a plain `+=` loop, since rewriting the last char with
448
+ // `out.slice(0, -1) + ...` copied the whole string a second time per byte.
449
+ // The high nibble is held in a local until its pair arrives instead.
450
+ const out = [];
451
+ let hi = 0;
438
452
  for (let i = 0; i < value.length; i++) {
439
453
  const c = value.charCodeAt(i);
440
454
  const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
441
455
  if (d < 0) return __sbToBytes(value);
442
- if (i % 2 === 0) out += String.fromCharCode(d << 4);
443
- else out = out.slice(0, -1) + String.fromCharCode(out.charCodeAt(out.length - 1) | d);
456
+ if (i % 2 === 0) hi = d << 4;
457
+ else out.push(String.fromCharCode(hi | d));
444
458
  }
445
- return out;
459
+ return out.join("");
446
460
  }
447
461
  return __sbToBytes(value);
448
462
  }
@@ -0,0 +1,114 @@
1
+ // sproutboat #180 — __sbToBytes lives inline in native-fetch-prelude.js (the
2
+ // prelude is prepended as text and cannot import), so this test lifts that one
3
+ // pure-JS function (plus its __sbIsStr dependency) out and exercises it
4
+ // directly. If it moves or its boundary markers change, this fails loudly
5
+ // rather than silently testing nothing.
6
+ import { expect, test } from "bun:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
11
+ const isStrStart = src.indexOf("function __sbIsStr(");
12
+ const isStrEnd = src.indexOf("function __sbIsFn(");
13
+ const start = src.indexOf("function __sbToBytes(");
14
+ const end = src.indexOf("function __sbBufFrom(");
15
+ const hexStart = src.indexOf("function __sbHexOrBytes(");
16
+ const hexEnd = src.indexOf("\nif (globalThis.crypto.subtle");
17
+ if (isStrStart === -1 || isStrEnd === -1 || start === -1 || end === -1 || end < start) {
18
+ throw new Error("__sbToBytes (or __sbIsStr) block not found in prelude");
19
+ }
20
+ if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sbHexOrBytes block not found in prelude");
21
+
22
+ // oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
23
+ const load = new Function(
24
+ `${src.slice(isStrStart, isStrEnd)}\n${src.slice(start, end)}\n${src.slice(hexStart, hexEnd)}\nreturn { __sbToBytes, __sbHexOrBytes };`,
25
+ );
26
+ const { __sbToBytes, __sbHexOrBytes } = load();
27
+
28
+ /** The real bytestring shape (one char per raw byte) as a normal JS array of byte values, for comparison. */
29
+ function bytesOf(bytestring) {
30
+ const out = [];
31
+ for (let i = 0; i < bytestring.length; i++) out.push(bytestring.charCodeAt(i));
32
+ return out;
33
+ }
34
+
35
+ test("ASCII string encodes byte-identically to TextEncoder", () => {
36
+ const text = "hello world 123";
37
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
38
+ });
39
+
40
+ test("2-byte (Latin-1 range) sequences encode byte-identically to TextEncoder", () => {
41
+ const text = "café";
42
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
43
+ });
44
+
45
+ test("3-byte sequences encode byte-identically to TextEncoder", () => {
46
+ const text = "porffor · x86_64 ✓";
47
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
48
+ });
49
+
50
+ test("surrogate-pair (4-byte) sequences encode byte-identically to TextEncoder", () => {
51
+ const text = "🌱 sprout 🌱";
52
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
53
+ });
54
+
55
+ test("a lone unpaired surrogate falls into the 3-byte branch, matching charCodeAt-based encoders", () => {
56
+ const lone = String.fromCharCode(0xd800); // high surrogate with no following low surrogate
57
+ const view = __sbToBytes(lone);
58
+ // WHATWG TextEncoder would replace this with U+FFFD; __sbToBytes intentionally
59
+ // encodes the raw code unit instead (matches the historical/legacy behavior it
60
+ // already had) -- this test only pins that behavior isn't disturbed by the fix.
61
+ expect(bytesOf(view)).toEqual([0xe0 | (0xd800 >> 12), 0x80 | ((0xd800 >> 6) & 0x3f), 0x80 | (0xd800 & 0x3f)]);
62
+ });
63
+
64
+ test("null/undefined input returns empty", () => {
65
+ expect(__sbToBytes(null)).toBe("");
66
+ expect(__sbToBytes(undefined)).toBe("");
67
+ });
68
+
69
+ test("ArrayBuffer / typed-array input is copied byte for byte, high bytes included", () => {
70
+ const bytes = new Uint8Array([0x00, 0x7f, 0x80, 0xff, 0x01, 0xfe]);
71
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
72
+ expect(bytesOf(__sbToBytes(bytes.buffer))).toEqual([...bytes]);
73
+ });
74
+
75
+ // __sbToBytes builds its result via an array + one join(), not repeated
76
+ // `out +=` / `s +=`. Porffor's strings have no rope/cons optimization, so `+=`
77
+ // in a loop copies the whole accumulated string on every append, making a
78
+ // large encode O(n^2) -- the same bug class __sbFromUtf8 was fixed for
79
+ // (see utf8-decode.test.js). This is a correctness check (long input still
80
+ // round-trips exactly), not a timing assertion.
81
+ test("a long, multi-chunk mixed-width string round-trips byte-identically", () => {
82
+ const chunk = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓ café 🌱 ";
83
+ const text = chunk.repeat(800); // ~46KB, comparable to the __sbFromUtf8 regression case
84
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
85
+ });
86
+
87
+ test("a long high-byte typed array round-trips byte for byte", () => {
88
+ const bytes = new Uint8Array(50000);
89
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
90
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
91
+ });
92
+
93
+ // __sbHexOrBytes had the same O(n^2) shape on a third path (it backs the
94
+ // `expected` side of the HMAC verify), plus a `slice(0, -1) + ...` rewrite that
95
+ // copied the accumulated string a second time per byte. Same array+join fix.
96
+ test("an even-length hex string decodes to its bytes, both cases", () => {
97
+ expect(bytesOf(__sbHexOrBytes("00ff107a"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
98
+ expect(bytesOf(__sbHexOrBytes("00FF107A"))).toEqual([0x00, 0xff, 0x10, 0x7a]);
99
+ expect(bytesOf(__sbHexOrBytes("aAbB"))).toEqual([0xaa, 0xbb]);
100
+ });
101
+
102
+ test("a non-hex or odd-length string falls back to raw bytes", () => {
103
+ expect(__sbHexOrBytes("zz")).toBe(__sbToBytes("zz"));
104
+ expect(__sbHexOrBytes("abc")).toBe(__sbToBytes("abc")); // odd length
105
+ expect(__sbHexOrBytes("")).toBe(__sbToBytes(""));
106
+ expect(bytesOf(__sbHexOrBytes(new Uint8Array([0x80, 0x01])))).toEqual([0x80, 0x01]);
107
+ });
108
+
109
+ test("a long hex string decodes byte for byte", () => {
110
+ const bytes = new Uint8Array(20000);
111
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
112
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
113
+ expect(bytesOf(__sbHexOrBytes(hex))).toEqual([...bytes]);
114
+ });
@@ -1191,13 +1191,13 @@ function __sbEnsureSchema() {
1191
1191
  }
1192
1192
 
1193
1193
  function __sbHex(n) {
1194
- let out = "";
1194
+ const out = [];
1195
1195
  const bytes = __sbRandomBytes(String(n));
1196
1196
  for (let i = 0; i < bytes.length; i++) {
1197
1197
  const h = bytes.charCodeAt(i).toString(16);
1198
- out += h.length === 1 ? "0" + h : h;
1198
+ out.push(h.length === 1 ? "0" + h : h);
1199
1199
  }
1200
- return out;
1200
+ return out.join("");
1201
1201
  }
1202
1202
 
1203
1203
  function __sbEmbeddedDispatch(msg) {
@@ -1279,14 +1279,15 @@ function __sbEmbeddedDispatch(msg) {
1279
1279
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1280
1280
  const allow = bindingsOutbound();
1281
1281
  if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1282
- let headerText = "";
1282
+ const headerParts = [];
1283
1283
  const pairs = msg.headers || [];
1284
1284
  for (let i = 0; i < pairs.length; i++) {
1285
1285
  const key = String(pairs[i][0]).toLowerCase();
1286
1286
  // Host, Connection and Content-Length are ours to set.
1287
1287
  if (key === "host" || key === "connection" || key === "content-length") continue;
1288
- headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
1288
+ headerParts.push(pairs[i][0] + ": " + pairs[i][1] + "\r\n");
1289
1289
  }
1290
+ const headerText = headerParts.join("");
1290
1291
  const port = url.port ? url.port : tls ? "443" : "80";
1291
1292
  const reply = JSON.parse(
1292
1293
  __sbHttpRaw(