@sproutboat/runtime 0.11.0 → 0.12.1
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 +22 -2
- package/package.json +1 -1
- package/src/form-data.test.js +81 -0
- package/src/native-fetch-prelude.js +214 -31
- package/src/transport-embedded.js +47 -0
- package/test/native-crypto.ts +128 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ed9640a: Point Porffor patch diagnostics, runtime comments, and historical notes to the
|
|
8
|
+
shared upstream notes in the Sproutboat platform repository.
|
|
9
|
+
|
|
10
|
+
## 0.12.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 4a7fe62: Cache API: `caches.default` / `caches.open(name)` (baronunread/sproutboat#59), with `match()`/`put()`/`delete()` backed by both transports (broker's `cache.*` ops and the standalone embedded transport, so behavior matches whichever way the app deploys). Keyed on method+URL for v1 (no `Vary` support yet). `put()` throws for a non-GET request, matching the spec; a 206 status or an explicit `no-store`/`private` `Cache-Control` is a silent no-op rather than an error, matching Workers' own `caches.default`. TTL derives from `s-maxage` (preferred) or `max-age`; entries with neither have no expiry.
|
|
15
|
+
|
|
16
|
+
Deliberately synchronous, not Promise-returning: every other buffered-body method this runtime already ships (`text()`/`json()`/`arrayBuffer()`/`blob()`/`formData()`) is sync too, and this runtime's own coroutine machinery has a known, still-open memory-corruption issue tied specifically to nested `async function` calls accumulating across many requests (baronunread/sproutboat#168) — no reason to add more async surface here. `await caches.default.match(...)` still reads correctly: awaiting a non-promise value resolves to it immediately.
|
|
17
|
+
- 96f97e2: `Request`/`Response` gain `formData()` (baronunread/sproutboat#60), parsing the buffered body synchronously — `application/x-www-form-urlencoded` via the existing `URLSearchParams` shim, `multipart/form-data` by hand (RFC 7578 boundary parsing, file fields come back as `Blob`s). Also adds `bytes()` (the `Uint8Array` shorthand over `arrayBuffer()`) to both classes. Everything is feature-detected, so this is a no-op the moment Porffor ships either natively.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 81dc1f7: Preserve raw bytes in `crypto.subtle.digest`, HMAC signing, and `crypto.scryptVerify`. The runtime's byte conversion already produces UTF-8 bytes for text and copies BufferSource inputs exactly, so the native bridge must pass those bytes through without encoding them again. This fixes repeated HMAC signing with digest bytes above `0x7f`.
|
|
22
|
+
|
|
3
23
|
## 0.11.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
|
@@ -118,11 +138,11 @@
|
|
|
118
138
|
|
|
119
139
|
Verified end-to-end against both transports (kitchen-sink's full conformance suite, 24/24 standalone + 27/27 broker) and with new unit tests for file-backed persistence, the `:memory:` fallback, and a real binary round-trip over the v1 wire protocol.
|
|
120
140
|
|
|
121
|
-
**Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage
|
|
141
|
+
**Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage: `porf_native_fetch_read_value` (the function every inline-C string read in the runtime goes through) always UTF-8-encodes a Latin-1-range string on read, which is correct for genuine text but wrong for opaque bytes coming off a raw HTTP body. This predates this change (the old SQLite-blob path had the identical corruption) and is not something the storage backend can fix. It needs an input-side counterpart to `#176`'s `x-sb-raw-body` marker, or real `ArrayBuffer`/`Uint8Array` body support (the upstream ask was filed as Porffor #386 and later retracted; see `https://github.com/baronunread/sproutboat/blob/main/patches/upstream/request-body-report-386.md`).
|
|
122
142
|
|
|
123
143
|
### Patch Changes
|
|
124
144
|
|
|
125
|
-
- 188d8e4: Fix (baronunread/sproutboat#184): a `put()` body (R2, both transports) with bytes ≥ 0x80 was corrupted on the way *in*, before storage
|
|
145
|
+
- 188d8e4: Fix (baronunread/sproutboat#184): a `put()` body (R2, both transports) with bytes ≥ 0x80 was corrupted on the way *in*, before storage: `porf_native_fetch_read_value` (the one function every inline-C string read in the runtime funnels through) always UTF-8-encodes a Latin-1-range value, correct for genuine text, wrong for opaque bytes off a raw HTTP upload. A 256-byte binary upload landed as 384 stored bytes.
|
|
126
146
|
|
|
127
147
|
`__sbR2PutRaw` (embedded) and `__sbCallBin` (broker, the only caller that ever passes it a real body) now try `porf_native_fetch_read_raw_bytes` first, falling back to the normal encoding path only for a genuine multi-byte JS string (which isn't a "bytestring" and so isn't eligible for the raw read anyway). This predates the #56 storage change — the old SQLite-blob `put()` called the identical `read_value`, so it was equally corrupted; the existing R2 conformance check never caught it because it only ever exercises a plain ASCII string.
|
|
128
148
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// #60 — the FormData parsing helpers live inline in native-fetch-prelude.js
|
|
2
|
+
// (the prelude is prepended as text and cannot import), so this test lifts
|
|
3
|
+
// that block out and exercises it directly. Bun already provides real
|
|
4
|
+
// FormData/Blob/URLSearchParams globals, so the lifted parser functions run
|
|
5
|
+
// against those rather than the prelude's own guarded shims (which no-op
|
|
6
|
+
// here since Bun already has the globals they'd otherwise install) — the
|
|
7
|
+
// parsing logic itself is what's under test, and it's spec surface either way.
|
|
8
|
+
import { expect, test } from "bun:test";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
|
|
13
|
+
const start = src.indexOf("function __sbParseUrlencodedFormData(");
|
|
14
|
+
const end = src.indexOf('if (!("formData" in Request.prototype))');
|
|
15
|
+
if (start === -1 || end === -1 || end < start) throw new Error("FormData parsing block not found in prelude");
|
|
16
|
+
|
|
17
|
+
// oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
|
|
18
|
+
const load = new Function(
|
|
19
|
+
`${src.slice(start, end)}\n return { __sbParseUrlencodedFormData, __sbParseMultipartFormData, __sbFormDataFromBody };`,
|
|
20
|
+
);
|
|
21
|
+
const { __sbParseUrlencodedFormData, __sbParseMultipartFormData, __sbFormDataFromBody } = load();
|
|
22
|
+
|
|
23
|
+
const headers = (contentType) => ({ get: (k) => (k.toLowerCase() === "content-type" ? contentType : null) });
|
|
24
|
+
|
|
25
|
+
test("urlencoded: decodes fields, including repeated keys and +/percent escapes", () => {
|
|
26
|
+
const fd = __sbParseUrlencodedFormData("name=Ren%C3%A9&tag=a&tag=b¬e=hi+there");
|
|
27
|
+
expect(fd.get("name")).toBe("René");
|
|
28
|
+
expect(fd.getAll("tag")).toEqual(["a", "b"]);
|
|
29
|
+
expect(fd.get("note")).toBe("hi there");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("urlencoded: empty body yields an empty FormData", () => {
|
|
33
|
+
const fd = __sbParseUrlencodedFormData("");
|
|
34
|
+
expect(fd.get("anything")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("multipart: parses a text field and a file field from a real boundary", () => {
|
|
38
|
+
const boundary = "----sbTestBoundary123";
|
|
39
|
+
const body =
|
|
40
|
+
`--${boundary}\r\n` +
|
|
41
|
+
`Content-Disposition: form-data; name="title"\r\n\r\n` +
|
|
42
|
+
`hello world\r\n` +
|
|
43
|
+
`--${boundary}\r\n` +
|
|
44
|
+
`Content-Disposition: form-data; name="upload"; filename="a.txt"\r\n` +
|
|
45
|
+
`Content-Type: text/plain\r\n\r\n` +
|
|
46
|
+
`file contents\r\n` +
|
|
47
|
+
`--${boundary}--\r\n`;
|
|
48
|
+
const fd = __sbParseMultipartFormData(body, boundary);
|
|
49
|
+
expect(fd.get("title")).toBe("hello world");
|
|
50
|
+
const file = fd.get("upload");
|
|
51
|
+
expect(file instanceof Blob).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("multipart: a part with no name= is skipped rather than throwing", () => {
|
|
55
|
+
const boundary = "B";
|
|
56
|
+
const body = `--B\r\nContent-Disposition: form-data\r\n\r\nignored\r\n--B\r\nContent-Disposition: form-data; name="k"\r\n\r\nv\r\n--B--\r\n`;
|
|
57
|
+
const fd = __sbParseMultipartFormData(body, boundary);
|
|
58
|
+
expect(fd.get("k")).toBe("v");
|
|
59
|
+
expect(fd.has("")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("__sbFormDataFromBody: dispatches on content-type, quoted or bare boundary", () => {
|
|
63
|
+
const urlencoded = __sbFormDataFromBody(headers("application/x-www-form-urlencoded"), "a=1");
|
|
64
|
+
expect(urlencoded.get("a")).toBe("1");
|
|
65
|
+
|
|
66
|
+
const bareBoundary = __sbFormDataFromBody(
|
|
67
|
+
headers("multipart/form-data; boundary=XYZ"),
|
|
68
|
+
`--XYZ\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--XYZ--\r\n`,
|
|
69
|
+
);
|
|
70
|
+
expect(bareBoundary.get("a")).toBe("1");
|
|
71
|
+
|
|
72
|
+
const quotedBoundary = __sbFormDataFromBody(
|
|
73
|
+
headers('multipart/form-data; boundary="XYZ"'),
|
|
74
|
+
`--XYZ\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--XYZ--\r\n`,
|
|
75
|
+
);
|
|
76
|
+
expect(quotedBoundary.get("a")).toBe("1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("__sbFormDataFromBody: multipart with no boundary throws instead of silently misparsing", () => {
|
|
80
|
+
expect(() => __sbFormDataFromBody(headers("multipart/form-data"), "")).toThrow(/boundary/);
|
|
81
|
+
});
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// Prepended to every handler by tools/compile.ts, before Porffor's native-fetch
|
|
2
|
-
//
|
|
2
|
+
// bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-4)
|
|
3
3
|
// gives URL (href/origin/pathname/search only) and Response without a static
|
|
4
4
|
// json(). This adds, additively, the rest of the WHATWG surface Worker code
|
|
5
5
|
// expects: URLSearchParams (read + write), URL.prototype.searchParams and the
|
|
6
6
|
// protocol/host/hostname/port/hash accessors, static Response.json,
|
|
7
7
|
// crypto.randomUUID / crypto.getRandomValues, crypto.subtle (digest + HMAC
|
|
8
|
-
// sign/verify, #133),
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// sign/verify, #133), structuredClone, and FormData + Request/Response
|
|
9
|
+
// formData()/bytes() (#60). Each is feature-detected; delete a block once
|
|
10
|
+
// Porffor ships that global. crypto.scryptVerify (#153) is a Sproutboat
|
|
11
|
+
// extension, not WHATWG, and stays.
|
|
12
|
+
// Tracked in the platform's upstream notes:
|
|
13
|
+
// https://github.com/baronunread/sproutboat/blob/main/patches/upstream/whatwg-fetch-globals.md
|
|
12
14
|
//
|
|
13
15
|
// Declared before it is referenced: a getter body that names a later top-level
|
|
14
|
-
// class throws ReferenceError in Porffor
|
|
16
|
+
// class throws ReferenceError in Porffor. See:
|
|
17
|
+
// https://github.com/baronunread/sproutboat/blob/main/patches/upstream/class-declaration-scope.md
|
|
15
18
|
|
|
16
19
|
// Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
|
|
17
20
|
// these express the same spec-mandated checks and are verified under Porffor
|
|
@@ -201,10 +204,192 @@ if (Response.json == null) {
|
|
|
201
204
|
};
|
|
202
205
|
}
|
|
203
206
|
|
|
207
|
+
// #59 — caches.default / caches.open(name), backed by the broker's cache.*
|
|
208
|
+
// ops (a sibling SQLite table, key on method+URL for v1 — no Vary yet).
|
|
209
|
+
// Deliberately NOT async: every other buffered-body method on this runtime
|
|
210
|
+
// (text/json/arrayBuffer/blob, formData above) is sync too, and #168's own
|
|
211
|
+
// investigation found real coroutine-state corruption tied specifically to
|
|
212
|
+
// nested `async function` calls accumulating across many requests — no
|
|
213
|
+
// reason to add more async surface to that here. `await`ing a non-promise
|
|
214
|
+
// still resolves immediately, so `await caches.default.match(...)` reads
|
|
215
|
+
// exactly like the spec's Promise-returning version to calling code.
|
|
216
|
+
if (globalThis.caches == null) {
|
|
217
|
+
function __sbCacheKey(request) {
|
|
218
|
+
return "GET " + (__sbIsStr(request) ? request : request.url);
|
|
219
|
+
}
|
|
220
|
+
class __SproutboatCache {
|
|
221
|
+
constructor(name) {
|
|
222
|
+
this._name = name;
|
|
223
|
+
}
|
|
224
|
+
match(request) {
|
|
225
|
+
const r = __sbRpc("cache.match", { name: this._name, key: __sbCacheKey(request) });
|
|
226
|
+
if (!r.found) return undefined;
|
|
227
|
+
return new Response(r.body, { status: r.status, headers: r.headers });
|
|
228
|
+
}
|
|
229
|
+
put(request, response) {
|
|
230
|
+
const method = __sbIsStr(request) ? "GET" : request.method;
|
|
231
|
+
if (method !== "GET") throw new TypeError("Cache.put: request method must be GET");
|
|
232
|
+
const headers = {};
|
|
233
|
+
response.headers.forEach((value, name) => {
|
|
234
|
+
headers[name] = value;
|
|
235
|
+
});
|
|
236
|
+
__sbRpc("cache.put", {
|
|
237
|
+
name: this._name,
|
|
238
|
+
key: __sbCacheKey(request),
|
|
239
|
+
status: response.status,
|
|
240
|
+
headers,
|
|
241
|
+
body: response.text(),
|
|
242
|
+
});
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
delete(request) {
|
|
246
|
+
return !!__sbRpc("cache.delete", { name: this._name, key: __sbCacheKey(request) }).deleted;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const __sbCacheInstances = new Map();
|
|
250
|
+
globalThis.caches = {
|
|
251
|
+
default: new __SproutboatCache("default"),
|
|
252
|
+
open(name) {
|
|
253
|
+
const key = String(name);
|
|
254
|
+
let instance = __sbCacheInstances.get(key);
|
|
255
|
+
if (!instance) {
|
|
256
|
+
instance = new __SproutboatCache(key);
|
|
257
|
+
__sbCacheInstances.set(key, instance);
|
|
258
|
+
}
|
|
259
|
+
return instance;
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// #60 — Porffor's Request/Response give text()/json()/arrayBuffer()/blob()
|
|
265
|
+
// synchronously (buffered body, fine under http-sync-v0) but no FormData at
|
|
266
|
+
// all. Parses the same buffered body, synchronously, into a FormData-like
|
|
267
|
+
// object; url-encoded via the URLSearchParams shim above, multipart by hand
|
|
268
|
+
// (RFC 7578: boundary-delimited parts, each a header block then a blank line
|
|
269
|
+
// then the part body — one pass, no nested multipart).
|
|
270
|
+
if (globalThis.FormData == null) {
|
|
271
|
+
class __SproutboatFormData {
|
|
272
|
+
constructor() {
|
|
273
|
+
this._entries = [];
|
|
274
|
+
}
|
|
275
|
+
append(name, value, filename) {
|
|
276
|
+
this._entries.push([String(name), value, filename]);
|
|
277
|
+
}
|
|
278
|
+
set(name, value, filename) {
|
|
279
|
+
name = String(name);
|
|
280
|
+
let replaced = false;
|
|
281
|
+
this._entries = this._entries.filter((e) => {
|
|
282
|
+
if (e[0] !== name) return true;
|
|
283
|
+
if (replaced) return false;
|
|
284
|
+
e[1] = value;
|
|
285
|
+
e[2] = filename;
|
|
286
|
+
replaced = true;
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
if (!replaced) this.append(name, value, filename);
|
|
290
|
+
}
|
|
291
|
+
get(name) {
|
|
292
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return this._entries[i][1];
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
getAll(name) {
|
|
296
|
+
const out = [];
|
|
297
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) out.push(this._entries[i][1]);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
has(name) {
|
|
301
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return true;
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
delete(name) {
|
|
305
|
+
this._entries = this._entries.filter((e) => e[0] !== name);
|
|
306
|
+
}
|
|
307
|
+
forEach(cb) {
|
|
308
|
+
for (let i = 0; i < this._entries.length; i++) cb(this._entries[i][1], this._entries[i][0], this);
|
|
309
|
+
}
|
|
310
|
+
keys() {
|
|
311
|
+
return this._entries.map((e) => e[0]);
|
|
312
|
+
}
|
|
313
|
+
values() {
|
|
314
|
+
return this._entries.map((e) => e[1]);
|
|
315
|
+
}
|
|
316
|
+
entries() {
|
|
317
|
+
return this._entries.map((e) => [e[0], e[1]]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
globalThis.FormData = __SproutboatFormData;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function __sbParseUrlencodedFormData(text) {
|
|
324
|
+
const fd = new FormData();
|
|
325
|
+
// forEach, not keys()/values() — those are spec'd as iterators, not
|
|
326
|
+
// arrays, and this shim's own URLSearchParams.keys()/values() return
|
|
327
|
+
// arrays as an implementation shortcut, so relying on either shape here
|
|
328
|
+
// would break against whichever kind actually ends up installed.
|
|
329
|
+
new URLSearchParams(text).forEach((value, key) => fd.append(key, value));
|
|
330
|
+
return fd;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function __sbParseMultipartFormData(text, boundary) {
|
|
334
|
+
const fd = new FormData();
|
|
335
|
+
const delim = "--" + boundary;
|
|
336
|
+
const segments = text.split(delim);
|
|
337
|
+
// segments[0] is the preamble, the last is the closing "--" epilogue.
|
|
338
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
339
|
+
let part = segments[i];
|
|
340
|
+
if (part.slice(0, 2) === "\r\n") part = part.slice(2);
|
|
341
|
+
if (part.slice(-2) === "\r\n") part = part.slice(0, -2);
|
|
342
|
+
const headerEnd = part.indexOf("\r\n\r\n");
|
|
343
|
+
if (headerEnd === -1) continue;
|
|
344
|
+
const headerText = part.slice(0, headerEnd);
|
|
345
|
+
const body = part.slice(headerEnd + 4);
|
|
346
|
+
const nameMatch = /name="([^"]*)"/i.exec(headerText);
|
|
347
|
+
if (!nameMatch) continue;
|
|
348
|
+
const filenameMatch = /filename="([^"]*)"/i.exec(headerText);
|
|
349
|
+
if (filenameMatch) fd.append(nameMatch[1], new Blob([body]), filenameMatch[1]);
|
|
350
|
+
else fd.append(nameMatch[1], body);
|
|
351
|
+
}
|
|
352
|
+
return fd;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function __sbFormDataFromBody(headers, text) {
|
|
356
|
+
const contentType = headers.get("content-type") || "";
|
|
357
|
+
if (contentType.indexOf("multipart/form-data") !== -1) {
|
|
358
|
+
const boundaryMatch = /boundary=(?:"([^"]*)"|([^;]+))/i.exec(contentType);
|
|
359
|
+
const boundary = boundaryMatch ? (boundaryMatch[1] || boundaryMatch[2]).trim() : null;
|
|
360
|
+
if (!boundary) throw new TypeError("multipart/form-data body has no boundary");
|
|
361
|
+
return __sbParseMultipartFormData(text, boundary);
|
|
362
|
+
}
|
|
363
|
+
return __sbParseUrlencodedFormData(text);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!("formData" in Request.prototype)) {
|
|
367
|
+
Request.prototype.formData = function () {
|
|
368
|
+
return __sbFormDataFromBody(this.headers, this.text());
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (!("formData" in Response.prototype)) {
|
|
372
|
+
Response.prototype.formData = function () {
|
|
373
|
+
return __sbFormDataFromBody(this.headers, this.text());
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// #60 — the newer Uint8Array shorthand, trivial on top of arrayBuffer().
|
|
377
|
+
if (!("bytes" in Request.prototype)) {
|
|
378
|
+
Request.prototype.bytes = function () {
|
|
379
|
+
return new Uint8Array(this.arrayBuffer());
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (!("bytes" in Response.prototype)) {
|
|
383
|
+
Response.prototype.bytes = function () {
|
|
384
|
+
return new Uint8Array(this.arrayBuffer());
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
204
388
|
// Porffor's URL exposes href / origin / pathname / search only. Add the rest of
|
|
205
389
|
// the WHATWG read surface, derived from `origin` (scheme://host[:port]).
|
|
206
390
|
// `hash` is always '' server-side — browsers strip the fragment before the
|
|
207
|
-
// request, so there is nothing to recover.
|
|
391
|
+
// request, so there is nothing to recover. See:
|
|
392
|
+
// https://github.com/baronunread/sproutboat/blob/main/patches/upstream/whatwg-fetch-globals.md
|
|
208
393
|
function __sbDefineURLAccessor(name, get) {
|
|
209
394
|
if (!(name in URL.prototype)) Object.defineProperty(URL.prototype, name, { configurable: true, get });
|
|
210
395
|
}
|
|
@@ -586,6 +771,7 @@ Porffor.c`
|
|
|
586
771
|
|
|
587
772
|
u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
|
|
588
773
|
int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
|
|
774
|
+
int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, size_t* out_len);
|
|
589
775
|
|
|
590
776
|
// --- #133: SHA-2 + HMAC ---------------------------------------------------
|
|
591
777
|
// Standalone links BearSSL, but a deployed sprout does not, and this inline C
|
|
@@ -917,8 +1103,8 @@ function __sbRandomBytes(nStr) {
|
|
|
917
1103
|
return out;
|
|
918
1104
|
}
|
|
919
1105
|
|
|
920
|
-
// #133
|
|
921
|
-
//
|
|
1106
|
+
// #133: one-shot SHA-2 digest. `algoStr` is "256"|"384"|"512"; `data` is a
|
|
1107
|
+
// bytestring (one char per byte). Returns the raw digest as a bytestring.
|
|
922
1108
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
|
|
923
1109
|
function __sbDigestRaw(algoStr, data) {
|
|
924
1110
|
let out = "";
|
|
@@ -931,17 +1117,17 @@ function __sbDigestRaw(algoStr, data) {
|
|
|
931
1117
|
if (__aso) free(__aso);
|
|
932
1118
|
int __algo = atoi(__ab);
|
|
933
1119
|
|
|
934
|
-
const char* __d; size_t __dl
|
|
935
|
-
|
|
1120
|
+
const char* __d = 0; size_t __dl = 0;
|
|
1121
|
+
// __sbToBytes has already encoded text. The generic reader would encode high bytes again.
|
|
1122
|
+
int __raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
|
|
936
1123
|
unsigned char __out[64];
|
|
937
|
-
size_t __n = sb_digest(__algo, (const unsigned char*)__d, __dl, __out);
|
|
938
|
-
if (__do) free(__do);
|
|
1124
|
+
size_t __n = __raw == 0 ? sb_digest(__algo, (const unsigned char*)__d, __dl, __out) : 0;
|
|
939
1125
|
if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
|
|
940
1126
|
`;
|
|
941
1127
|
return out;
|
|
942
1128
|
}
|
|
943
1129
|
|
|
944
|
-
// #133
|
|
1130
|
+
// #133: HMAC-SHA-2. `algoStr` "256"|"384"|"512"; `key` and `data` are bytestrings.
|
|
945
1131
|
// Returns the raw MAC as a bytestring.
|
|
946
1132
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
|
|
947
1133
|
function __sbHmacRaw(algoStr, key, data) {
|
|
@@ -955,17 +1141,15 @@ function __sbHmacRaw(algoStr, key, data) {
|
|
|
955
1141
|
if (__aso) free(__aso);
|
|
956
1142
|
int __algo = atoi(__ab);
|
|
957
1143
|
|
|
958
|
-
const char* __k; size_t __kl
|
|
959
|
-
|
|
960
|
-
unsigned char* __kc = (unsigned char*)malloc(__kl ? __kl : 1);
|
|
1144
|
+
const char* __k = 0; size_t __kl = 0;
|
|
1145
|
+
int __key_raw = porf_native_fetch_read_raw_bytes(key, &__k, &__kl);
|
|
1146
|
+
unsigned char* __kc = __key_raw == 0 ? (unsigned char*)malloc(__kl ? __kl : 1) : 0;
|
|
961
1147
|
if (__kc) memcpy(__kc, __k, __kl);
|
|
962
|
-
if (__ko) free(__ko);
|
|
963
1148
|
|
|
964
|
-
const char* __d; size_t __dl
|
|
965
|
-
|
|
1149
|
+
const char* __d = 0; size_t __dl = 0;
|
|
1150
|
+
int __data_raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
|
|
966
1151
|
unsigned char __out[64];
|
|
967
|
-
size_t __n = __kc ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
|
|
968
|
-
if (__do) free(__do);
|
|
1152
|
+
size_t __n = __kc && __data_raw == 0 ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
|
|
969
1153
|
if (__kc) free(__kc);
|
|
970
1154
|
if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
|
|
971
1155
|
`;
|
|
@@ -979,17 +1163,15 @@ function __sbScryptRaw(pw, salt, nStr, rStr, pStr, dkLenStr) {
|
|
|
979
1163
|
let out = "";
|
|
980
1164
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
981
1165
|
Porffor.c`
|
|
982
|
-
const char* __p; size_t __pl
|
|
983
|
-
|
|
984
|
-
unsigned char* __pc = (unsigned char*)malloc(__pl ? __pl : 1);
|
|
1166
|
+
const char* __p = 0; size_t __pl = 0;
|
|
1167
|
+
int __pw_raw = porf_native_fetch_read_raw_bytes(pw, &__p, &__pl);
|
|
1168
|
+
unsigned char* __pc = __pw_raw == 0 ? (unsigned char*)malloc(__pl ? __pl : 1) : 0;
|
|
985
1169
|
if (__pc) memcpy(__pc, __p, __pl);
|
|
986
|
-
if (__po) free(__po);
|
|
987
1170
|
|
|
988
|
-
const char* __s; size_t __sl
|
|
989
|
-
|
|
990
|
-
unsigned char* __sc = (unsigned char*)malloc(__sl ? __sl : 1);
|
|
1171
|
+
const char* __s = 0; size_t __sl = 0;
|
|
1172
|
+
int __salt_raw = porf_native_fetch_read_raw_bytes(salt, &__s, &__sl);
|
|
1173
|
+
unsigned char* __sc = __salt_raw == 0 ? (unsigned char*)malloc(__sl ? __sl : 1) : 0;
|
|
991
1174
|
if (__sc) memcpy(__sc, __s, __sl);
|
|
992
|
-
if (__so) free(__so);
|
|
993
1175
|
|
|
994
1176
|
long __N = 0, __r = 0, __pp = 0, __dk = 0;
|
|
995
1177
|
{ const char* q; size_t ql; char* qo = 0; char nb[24];
|
|
@@ -1637,7 +1819,8 @@ function __sbTriggerAuthed(request) {
|
|
|
1637
1819
|
// #163 — the connection's remote address, for `request.cf.clientIp`.
|
|
1638
1820
|
//
|
|
1639
1821
|
// `x-sb-remote-addr` is the TCP peer, appended by the server (see
|
|
1640
|
-
// patches/
|
|
1822
|
+
// https://github.com/baronunread/sproutboat/blob/main/patches/upstream/remote-address.md)
|
|
1823
|
+
// and stripped from anything a client sends, so it
|
|
1641
1824
|
// cannot be forged. With no proxy in front, that is the client. Behind one, set
|
|
1642
1825
|
// `SB_TRUSTED_PROXIES` to a comma-separated list of trusted CIDRs (or bare IPs):
|
|
1643
1826
|
// when the peer is trusted, the client is the rightmost `x-forwarded-for` entry
|
|
@@ -1854,6 +1854,11 @@ function __sbEnsureSchema() {
|
|
|
1854
1854
|
s,
|
|
1855
1855
|
"CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (name, key))",
|
|
1856
1856
|
);
|
|
1857
|
+
// #59 — caches.default, ambient like the broker's copy of this table.
|
|
1858
|
+
__sbSql(
|
|
1859
|
+
s,
|
|
1860
|
+
"CREATE TABLE IF NOT EXISTS cache (name TEXT NOT NULL, cache_key TEXT NOT NULL, status INTEGER NOT NULL, headers_json TEXT NOT NULL, body TEXT NOT NULL, expires_at INTEGER, PRIMARY KEY (name, cache_key))",
|
|
1861
|
+
);
|
|
1857
1862
|
}
|
|
1858
1863
|
|
|
1859
1864
|
function __sbHex(n) {
|
|
@@ -2426,6 +2431,48 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
2426
2431
|
return { ok: true, success: count <= limit, resetAt: (windowStart + period) * 1000 };
|
|
2427
2432
|
}
|
|
2428
2433
|
|
|
2434
|
+
if (op === "cache.match") {
|
|
2435
|
+
const name = msg.name || "default";
|
|
2436
|
+
const r = __sbSql(store, "SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?", [
|
|
2437
|
+
name,
|
|
2438
|
+
msg.key,
|
|
2439
|
+
]);
|
|
2440
|
+
if (!r.rows.length) return { ok: true, found: false };
|
|
2441
|
+
const [status, headersJson, body, expiresAt] = r.rows[0];
|
|
2442
|
+
if (expiresAt != null && expiresAt <= Date.now()) {
|
|
2443
|
+
__sbSql(store, "DELETE FROM cache WHERE name = ? AND cache_key = ?", [name, msg.key]);
|
|
2444
|
+
return { ok: true, found: false };
|
|
2445
|
+
}
|
|
2446
|
+
return { ok: true, found: true, status, headers: JSON.parse(headersJson), body };
|
|
2447
|
+
}
|
|
2448
|
+
if (op === "cache.put") {
|
|
2449
|
+
const name = msg.name || "default";
|
|
2450
|
+
const status = Number(msg.status) || 200;
|
|
2451
|
+
const headers = msg.headers || {};
|
|
2452
|
+
const cacheControl = String(headers["cache-control"] || headers["Cache-Control"] || "").toLowerCase();
|
|
2453
|
+
// Mirrors Workers' own caches.default.put(): a 206 or an explicit
|
|
2454
|
+
// no-store/private is a silent no-op, not an error.
|
|
2455
|
+
if (status === 206 || cacheControl.indexOf("no-store") !== -1 || cacheControl.indexOf("private") !== -1) {
|
|
2456
|
+
return { ok: true, stored: false };
|
|
2457
|
+
}
|
|
2458
|
+
const sMaxAge = /s-maxage=(\d+)/.exec(cacheControl);
|
|
2459
|
+
const maxAge = /max-age=(\d+)/.exec(cacheControl);
|
|
2460
|
+
const ttlSeconds = sMaxAge ? Number(sMaxAge[1]) : maxAge ? Number(maxAge[1]) : null;
|
|
2461
|
+
const expiresAt = ttlSeconds != null ? Date.now() + ttlSeconds * 1000 : null;
|
|
2462
|
+
__sbSql(
|
|
2463
|
+
store,
|
|
2464
|
+
"INSERT INTO cache (name, cache_key, status, headers_json, body, expires_at) VALUES (?1,?2,?3,?4,?5,?6) " +
|
|
2465
|
+
"ON CONFLICT (name, cache_key) DO UPDATE SET status = ?3, headers_json = ?4, body = ?5, expires_at = ?6",
|
|
2466
|
+
[name, msg.key, status, JSON.stringify(headers), msg.body, expiresAt],
|
|
2467
|
+
);
|
|
2468
|
+
return { ok: true, stored: true };
|
|
2469
|
+
}
|
|
2470
|
+
if (op === "cache.delete") {
|
|
2471
|
+
const name = msg.name || "default";
|
|
2472
|
+
const r = __sbSql(store, "DELETE FROM cache WHERE name = ? AND cache_key = ?", [name, msg.key]);
|
|
2473
|
+
return { ok: true, deleted: r.changes > 0 };
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2429
2476
|
throw new Error("unknown op: " + op);
|
|
2430
2477
|
}
|
|
2431
2478
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// These values come from Bun WebCrypto and node:crypto, not from the runtime
|
|
9
|
+
// under test. A sign/verify round trip would miss a shared input corruption.
|
|
10
|
+
const expected = {
|
|
11
|
+
rounds: [
|
|
12
|
+
"e94df4f03406bc36299dd6e6961b22b50fc963fbbf8aeb773f8d7960a77e741e",
|
|
13
|
+
"1321aaf34d2ad833bd0c8af3ab6c3a91f2be090fd9631538c88ec3986b6eac28",
|
|
14
|
+
],
|
|
15
|
+
digest: "89273d2f70b93285bb7ddb4bcee86a5347ca7159352e3cbdd20c23e9d1e507d3",
|
|
16
|
+
keyed: "ae70bbf366f791b87ae2c2642bf5bee0e7115f09ec9d15d407b8e5fb60ae933c",
|
|
17
|
+
scrypt: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const handler = `
|
|
21
|
+
import { keyBytes } from "./crypto-helper.js";
|
|
22
|
+
const hex = (buffer) => {
|
|
23
|
+
const bytes = new Uint8Array(buffer);
|
|
24
|
+
let out = "";
|
|
25
|
+
for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
export default {
|
|
29
|
+
async fetch() {
|
|
30
|
+
const key = await crypto.subtle.importKey(
|
|
31
|
+
"raw",
|
|
32
|
+
keyBytes(),
|
|
33
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
34
|
+
false,
|
|
35
|
+
["sign"],
|
|
36
|
+
);
|
|
37
|
+
let data = new TextEncoder().encode("h1-regression-password-0001");
|
|
38
|
+
const rounds = [];
|
|
39
|
+
for (let i = 0; i < 2; i++) {
|
|
40
|
+
data = new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
|
|
41
|
+
rounds.push(hex(data));
|
|
42
|
+
}
|
|
43
|
+
const binary = new Uint8Array([0x00, 0x7f, 0x80, 0xff]);
|
|
44
|
+
const binaryKey = await crypto.subtle.importKey(
|
|
45
|
+
"raw", new Uint8Array([0x80, 0xff]), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
46
|
+
);
|
|
47
|
+
const digest = hex(await crypto.subtle.digest("SHA-256", binary));
|
|
48
|
+
const keyed = hex(await crypto.subtle.sign("HMAC", binaryKey, binary));
|
|
49
|
+
const scrypt = crypto.scryptVerify(
|
|
50
|
+
"café",
|
|
51
|
+
binary,
|
|
52
|
+
"4459ed251d645d761e6d111db6bedeb64a8e3e8a9ad2b30dce4550ae9fc35049",
|
|
53
|
+
{ N: 16, r: 1, p: 1 },
|
|
54
|
+
);
|
|
55
|
+
return Response.json({ rounds, digest, keyed, scrypt });
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
function freePort(): number {
|
|
61
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
62
|
+
const port = listener.port;
|
|
63
|
+
listener.stop(true);
|
|
64
|
+
return port;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-crypto-"));
|
|
68
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
69
|
+
try {
|
|
70
|
+
const porffor = await ensurePorffor({ cacheRoot: workdir });
|
|
71
|
+
const node = Bun.which("node");
|
|
72
|
+
if (!node) throw new Error("native crypto test needs node on PATH");
|
|
73
|
+
|
|
74
|
+
const [core, broker] = await Promise.all([
|
|
75
|
+
readFile(preludePath, "utf8"),
|
|
76
|
+
readFile(transportPath("broker"), "utf8"),
|
|
77
|
+
]);
|
|
78
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
79
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
80
|
+
const generated = join(workdir, "crypto.generated.js");
|
|
81
|
+
const binary = join(workdir, "crypto-native");
|
|
82
|
+
// This is a direct ESM import into Porffor, with no bundler in front of it.
|
|
83
|
+
await writeFile(join(workdir, "crypto-helper.js"),
|
|
84
|
+
"export const keyBytes = () => Uint8Array.from({ length: 16 }, (_, index) => index + 1);\n");
|
|
85
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
86
|
+
await rm(binary, { force: true });
|
|
87
|
+
|
|
88
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
89
|
+
cwd: workdir,
|
|
90
|
+
env: process.env,
|
|
91
|
+
stdout: "pipe",
|
|
92
|
+
stderr: "pipe",
|
|
93
|
+
});
|
|
94
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
95
|
+
compile.exited,
|
|
96
|
+
new Response(compile.stdout).text(),
|
|
97
|
+
new Response(compile.stderr).text(),
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
100
|
+
|
|
101
|
+
const port = freePort();
|
|
102
|
+
server = Bun.spawn([binary], {
|
|
103
|
+
cwd: workdir,
|
|
104
|
+
env: { ...process.env, PORT: String(port) },
|
|
105
|
+
stdout: "ignore",
|
|
106
|
+
stderr: "inherit",
|
|
107
|
+
});
|
|
108
|
+
let response: Response | undefined;
|
|
109
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
110
|
+
try {
|
|
111
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
112
|
+
break;
|
|
113
|
+
} catch {
|
|
114
|
+
if (server.exitCode !== null) break;
|
|
115
|
+
await Bun.sleep(100);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
assert(response, "compiled crypto handler did not start");
|
|
119
|
+
assert.equal(response.status, 200);
|
|
120
|
+
assert.deepEqual(await response.json(), expected);
|
|
121
|
+
console.log("Native crypto vectors match Bun WebCrypto and node:crypto");
|
|
122
|
+
} finally {
|
|
123
|
+
if (server) {
|
|
124
|
+
server.kill(9);
|
|
125
|
+
await server.exited;
|
|
126
|
+
}
|
|
127
|
+
await rm(workdir, { recursive: true, force: true });
|
|
128
|
+
}
|