@sproutboat/runtime 0.10.0 → 0.12.0
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 +19 -0
- package/package.json +1 -1
- package/src/client-ip.test.js +20 -1
- package/src/form-data.test.js +81 -0
- package/src/native-fetch-prelude.js +226 -27
- package/src/transport-embedded.js +47 -0
- package/test/native-crypto.ts +125 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 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`.
|
|
15
|
+
|
|
16
|
+
## 0.11.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- d0e2c05: `request.cf` gains `httpProtocol`, `tlsVersion`, and `tlsCipher` (baronunread/sproutboat#128). Like `clientIp` (#163), these are only populated when the direct peer is listed in `SB_TRUSTED_PROXIES` and it forwarded the corresponding `x-sb-http-protocol` / `x-sb-tls-version` / `x-sb-tls-cipher` header — otherwise they stay absent rather than a guessed or faked value. `colo`, ASN, and geo fields remain out of scope (no GeoIP database on the box).
|
|
21
|
+
|
|
3
22
|
## 0.10.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/package.json
CHANGED
package/src/client-ip.test.js
CHANGED
|
@@ -17,7 +17,7 @@ const load = new Function(
|
|
|
17
17
|
"__envMap",
|
|
18
18
|
`${src.slice(start, end)}
|
|
19
19
|
const __sbEnv = (k) => __envMap[k];
|
|
20
|
-
return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList };`,
|
|
20
|
+
return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList, __sbTrustedProxyHeader };`,
|
|
21
21
|
);
|
|
22
22
|
const H = load(new Proxy({}, { get: (_, k) => env[k] }));
|
|
23
23
|
|
|
@@ -64,3 +64,22 @@ test("__sbClientIp: an untrusted peer cannot use XFF even if it sends one", () =
|
|
|
64
64
|
env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
|
|
65
65
|
expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe("198.51.100.9");
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test("__sbTrustedProxyHeader: no trusted proxies configured means the header is ignored", () => {
|
|
69
|
+
env = {};
|
|
70
|
+
const r = req({ "x-sb-remote-addr": "127.0.0.1", "x-sb-http-protocol": "HTTP/2" });
|
|
71
|
+
expect(H.__sbTrustedProxyHeader(r, "x-sb-http-protocol")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("__sbTrustedProxyHeader: an untrusted direct peer cannot fake it", () => {
|
|
75
|
+
env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
|
|
76
|
+
const r = req({ "x-sb-remote-addr": "198.51.100.9", "x-sb-tls-version": "TLSv1.3" });
|
|
77
|
+
expect(H.__sbTrustedProxyHeader(r, "x-sb-tls-version")).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("__sbTrustedProxyHeader: a trusted peer's value passes through, absent stays undefined", () => {
|
|
81
|
+
env = { SB_TRUSTED_PROXIES: "127.0.0.0/8" };
|
|
82
|
+
const r = req({ "x-sb-remote-addr": "127.0.0.1", "x-sb-http-protocol": "HTTP/2" });
|
|
83
|
+
expect(H.__sbTrustedProxyHeader(r, "x-sb-http-protocol")).toBe("HTTP/2");
|
|
84
|
+
expect(H.__sbTrustedProxyHeader(r, "x-sb-tls-cipher")).toBeUndefined();
|
|
85
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -5,9 +5,10 @@
|
|
|
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
|
-
//
|
|
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.
|
|
11
12
|
// Tracked upstream in patches/UPSTREAM.md.
|
|
12
13
|
//
|
|
13
14
|
// Declared before it is referenced: a getter body that names a later top-level
|
|
@@ -201,6 +202,187 @@ if (Response.json == null) {
|
|
|
201
202
|
};
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// #59 — caches.default / caches.open(name), backed by the broker's cache.*
|
|
206
|
+
// ops (a sibling SQLite table, key on method+URL for v1 — no Vary yet).
|
|
207
|
+
// Deliberately NOT async: every other buffered-body method on this runtime
|
|
208
|
+
// (text/json/arrayBuffer/blob, formData above) is sync too, and #168's own
|
|
209
|
+
// investigation found real coroutine-state corruption tied specifically to
|
|
210
|
+
// nested `async function` calls accumulating across many requests — no
|
|
211
|
+
// reason to add more async surface to that here. `await`ing a non-promise
|
|
212
|
+
// still resolves immediately, so `await caches.default.match(...)` reads
|
|
213
|
+
// exactly like the spec's Promise-returning version to calling code.
|
|
214
|
+
if (globalThis.caches == null) {
|
|
215
|
+
function __sbCacheKey(request) {
|
|
216
|
+
return "GET " + (__sbIsStr(request) ? request : request.url);
|
|
217
|
+
}
|
|
218
|
+
class __SproutboatCache {
|
|
219
|
+
constructor(name) {
|
|
220
|
+
this._name = name;
|
|
221
|
+
}
|
|
222
|
+
match(request) {
|
|
223
|
+
const r = __sbRpc("cache.match", { name: this._name, key: __sbCacheKey(request) });
|
|
224
|
+
if (!r.found) return undefined;
|
|
225
|
+
return new Response(r.body, { status: r.status, headers: r.headers });
|
|
226
|
+
}
|
|
227
|
+
put(request, response) {
|
|
228
|
+
const method = __sbIsStr(request) ? "GET" : request.method;
|
|
229
|
+
if (method !== "GET") throw new TypeError("Cache.put: request method must be GET");
|
|
230
|
+
const headers = {};
|
|
231
|
+
response.headers.forEach((value, name) => {
|
|
232
|
+
headers[name] = value;
|
|
233
|
+
});
|
|
234
|
+
__sbRpc("cache.put", {
|
|
235
|
+
name: this._name,
|
|
236
|
+
key: __sbCacheKey(request),
|
|
237
|
+
status: response.status,
|
|
238
|
+
headers,
|
|
239
|
+
body: response.text(),
|
|
240
|
+
});
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
delete(request) {
|
|
244
|
+
return !!__sbRpc("cache.delete", { name: this._name, key: __sbCacheKey(request) }).deleted;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const __sbCacheInstances = new Map();
|
|
248
|
+
globalThis.caches = {
|
|
249
|
+
default: new __SproutboatCache("default"),
|
|
250
|
+
open(name) {
|
|
251
|
+
const key = String(name);
|
|
252
|
+
let instance = __sbCacheInstances.get(key);
|
|
253
|
+
if (!instance) {
|
|
254
|
+
instance = new __SproutboatCache(key);
|
|
255
|
+
__sbCacheInstances.set(key, instance);
|
|
256
|
+
}
|
|
257
|
+
return instance;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// #60 — Porffor's Request/Response give text()/json()/arrayBuffer()/blob()
|
|
263
|
+
// synchronously (buffered body, fine under http-sync-v0) but no FormData at
|
|
264
|
+
// all. Parses the same buffered body, synchronously, into a FormData-like
|
|
265
|
+
// object; url-encoded via the URLSearchParams shim above, multipart by hand
|
|
266
|
+
// (RFC 7578: boundary-delimited parts, each a header block then a blank line
|
|
267
|
+
// then the part body — one pass, no nested multipart).
|
|
268
|
+
if (globalThis.FormData == null) {
|
|
269
|
+
class __SproutboatFormData {
|
|
270
|
+
constructor() {
|
|
271
|
+
this._entries = [];
|
|
272
|
+
}
|
|
273
|
+
append(name, value, filename) {
|
|
274
|
+
this._entries.push([String(name), value, filename]);
|
|
275
|
+
}
|
|
276
|
+
set(name, value, filename) {
|
|
277
|
+
name = String(name);
|
|
278
|
+
let replaced = false;
|
|
279
|
+
this._entries = this._entries.filter((e) => {
|
|
280
|
+
if (e[0] !== name) return true;
|
|
281
|
+
if (replaced) return false;
|
|
282
|
+
e[1] = value;
|
|
283
|
+
e[2] = filename;
|
|
284
|
+
replaced = true;
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
if (!replaced) this.append(name, value, filename);
|
|
288
|
+
}
|
|
289
|
+
get(name) {
|
|
290
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return this._entries[i][1];
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
getAll(name) {
|
|
294
|
+
const out = [];
|
|
295
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) out.push(this._entries[i][1]);
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
has(name) {
|
|
299
|
+
for (let i = 0; i < this._entries.length; i++) if (this._entries[i][0] === name) return true;
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
delete(name) {
|
|
303
|
+
this._entries = this._entries.filter((e) => e[0] !== name);
|
|
304
|
+
}
|
|
305
|
+
forEach(cb) {
|
|
306
|
+
for (let i = 0; i < this._entries.length; i++) cb(this._entries[i][1], this._entries[i][0], this);
|
|
307
|
+
}
|
|
308
|
+
keys() {
|
|
309
|
+
return this._entries.map((e) => e[0]);
|
|
310
|
+
}
|
|
311
|
+
values() {
|
|
312
|
+
return this._entries.map((e) => e[1]);
|
|
313
|
+
}
|
|
314
|
+
entries() {
|
|
315
|
+
return this._entries.map((e) => [e[0], e[1]]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
globalThis.FormData = __SproutboatFormData;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function __sbParseUrlencodedFormData(text) {
|
|
322
|
+
const fd = new FormData();
|
|
323
|
+
// forEach, not keys()/values() — those are spec'd as iterators, not
|
|
324
|
+
// arrays, and this shim's own URLSearchParams.keys()/values() return
|
|
325
|
+
// arrays as an implementation shortcut, so relying on either shape here
|
|
326
|
+
// would break against whichever kind actually ends up installed.
|
|
327
|
+
new URLSearchParams(text).forEach((value, key) => fd.append(key, value));
|
|
328
|
+
return fd;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function __sbParseMultipartFormData(text, boundary) {
|
|
332
|
+
const fd = new FormData();
|
|
333
|
+
const delim = "--" + boundary;
|
|
334
|
+
const segments = text.split(delim);
|
|
335
|
+
// segments[0] is the preamble, the last is the closing "--" epilogue.
|
|
336
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
337
|
+
let part = segments[i];
|
|
338
|
+
if (part.slice(0, 2) === "\r\n") part = part.slice(2);
|
|
339
|
+
if (part.slice(-2) === "\r\n") part = part.slice(0, -2);
|
|
340
|
+
const headerEnd = part.indexOf("\r\n\r\n");
|
|
341
|
+
if (headerEnd === -1) continue;
|
|
342
|
+
const headerText = part.slice(0, headerEnd);
|
|
343
|
+
const body = part.slice(headerEnd + 4);
|
|
344
|
+
const nameMatch = /name="([^"]*)"/i.exec(headerText);
|
|
345
|
+
if (!nameMatch) continue;
|
|
346
|
+
const filenameMatch = /filename="([^"]*)"/i.exec(headerText);
|
|
347
|
+
if (filenameMatch) fd.append(nameMatch[1], new Blob([body]), filenameMatch[1]);
|
|
348
|
+
else fd.append(nameMatch[1], body);
|
|
349
|
+
}
|
|
350
|
+
return fd;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function __sbFormDataFromBody(headers, text) {
|
|
354
|
+
const contentType = headers.get("content-type") || "";
|
|
355
|
+
if (contentType.indexOf("multipart/form-data") !== -1) {
|
|
356
|
+
const boundaryMatch = /boundary=(?:"([^"]*)"|([^;]+))/i.exec(contentType);
|
|
357
|
+
const boundary = boundaryMatch ? (boundaryMatch[1] || boundaryMatch[2]).trim() : null;
|
|
358
|
+
if (!boundary) throw new TypeError("multipart/form-data body has no boundary");
|
|
359
|
+
return __sbParseMultipartFormData(text, boundary);
|
|
360
|
+
}
|
|
361
|
+
return __sbParseUrlencodedFormData(text);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!("formData" in Request.prototype)) {
|
|
365
|
+
Request.prototype.formData = function () {
|
|
366
|
+
return __sbFormDataFromBody(this.headers, this.text());
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (!("formData" in Response.prototype)) {
|
|
370
|
+
Response.prototype.formData = function () {
|
|
371
|
+
return __sbFormDataFromBody(this.headers, this.text());
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// #60 — the newer Uint8Array shorthand, trivial on top of arrayBuffer().
|
|
375
|
+
if (!("bytes" in Request.prototype)) {
|
|
376
|
+
Request.prototype.bytes = function () {
|
|
377
|
+
return new Uint8Array(this.arrayBuffer());
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!("bytes" in Response.prototype)) {
|
|
381
|
+
Response.prototype.bytes = function () {
|
|
382
|
+
return new Uint8Array(this.arrayBuffer());
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
204
386
|
// Porffor's URL exposes href / origin / pathname / search only. Add the rest of
|
|
205
387
|
// the WHATWG read surface, derived from `origin` (scheme://host[:port]).
|
|
206
388
|
// `hash` is always '' server-side — browsers strip the fragment before the
|
|
@@ -586,6 +768,7 @@ Porffor.c`
|
|
|
586
768
|
|
|
587
769
|
u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
|
|
588
770
|
int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
|
|
771
|
+
int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, size_t* out_len);
|
|
589
772
|
|
|
590
773
|
// --- #133: SHA-2 + HMAC ---------------------------------------------------
|
|
591
774
|
// Standalone links BearSSL, but a deployed sprout does not, and this inline C
|
|
@@ -917,8 +1100,8 @@ function __sbRandomBytes(nStr) {
|
|
|
917
1100
|
return out;
|
|
918
1101
|
}
|
|
919
1102
|
|
|
920
|
-
// #133
|
|
921
|
-
//
|
|
1103
|
+
// #133: one-shot SHA-2 digest. `algoStr` is "256"|"384"|"512"; `data` is a
|
|
1104
|
+
// bytestring (one char per byte). Returns the raw digest as a bytestring.
|
|
922
1105
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
|
|
923
1106
|
function __sbDigestRaw(algoStr, data) {
|
|
924
1107
|
let out = "";
|
|
@@ -931,17 +1114,17 @@ function __sbDigestRaw(algoStr, data) {
|
|
|
931
1114
|
if (__aso) free(__aso);
|
|
932
1115
|
int __algo = atoi(__ab);
|
|
933
1116
|
|
|
934
|
-
const char* __d; size_t __dl
|
|
935
|
-
|
|
1117
|
+
const char* __d = 0; size_t __dl = 0;
|
|
1118
|
+
// __sbToBytes has already encoded text. The generic reader would encode high bytes again.
|
|
1119
|
+
int __raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
|
|
936
1120
|
unsigned char __out[64];
|
|
937
|
-
size_t __n = sb_digest(__algo, (const unsigned char*)__d, __dl, __out);
|
|
938
|
-
if (__do) free(__do);
|
|
1121
|
+
size_t __n = __raw == 0 ? sb_digest(__algo, (const unsigned char*)__d, __dl, __out) : 0;
|
|
939
1122
|
if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
|
|
940
1123
|
`;
|
|
941
1124
|
return out;
|
|
942
1125
|
}
|
|
943
1126
|
|
|
944
|
-
// #133
|
|
1127
|
+
// #133: HMAC-SHA-2. `algoStr` "256"|"384"|"512"; `key` and `data` are bytestrings.
|
|
945
1128
|
// Returns the raw MAC as a bytestring.
|
|
946
1129
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
|
|
947
1130
|
function __sbHmacRaw(algoStr, key, data) {
|
|
@@ -955,17 +1138,15 @@ function __sbHmacRaw(algoStr, key, data) {
|
|
|
955
1138
|
if (__aso) free(__aso);
|
|
956
1139
|
int __algo = atoi(__ab);
|
|
957
1140
|
|
|
958
|
-
const char* __k; size_t __kl
|
|
959
|
-
|
|
960
|
-
unsigned char* __kc = (unsigned char*)malloc(__kl ? __kl : 1);
|
|
1141
|
+
const char* __k = 0; size_t __kl = 0;
|
|
1142
|
+
int __key_raw = porf_native_fetch_read_raw_bytes(key, &__k, &__kl);
|
|
1143
|
+
unsigned char* __kc = __key_raw == 0 ? (unsigned char*)malloc(__kl ? __kl : 1) : 0;
|
|
961
1144
|
if (__kc) memcpy(__kc, __k, __kl);
|
|
962
|
-
if (__ko) free(__ko);
|
|
963
1145
|
|
|
964
|
-
const char* __d; size_t __dl
|
|
965
|
-
|
|
1146
|
+
const char* __d = 0; size_t __dl = 0;
|
|
1147
|
+
int __data_raw = porf_native_fetch_read_raw_bytes(data, &__d, &__dl);
|
|
966
1148
|
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);
|
|
1149
|
+
size_t __n = __kc && __data_raw == 0 ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
|
|
969
1150
|
if (__kc) free(__kc);
|
|
970
1151
|
if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
|
|
971
1152
|
`;
|
|
@@ -979,17 +1160,15 @@ function __sbScryptRaw(pw, salt, nStr, rStr, pStr, dkLenStr) {
|
|
|
979
1160
|
let out = "";
|
|
980
1161
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
981
1162
|
Porffor.c`
|
|
982
|
-
const char* __p; size_t __pl
|
|
983
|
-
|
|
984
|
-
unsigned char* __pc = (unsigned char*)malloc(__pl ? __pl : 1);
|
|
1163
|
+
const char* __p = 0; size_t __pl = 0;
|
|
1164
|
+
int __pw_raw = porf_native_fetch_read_raw_bytes(pw, &__p, &__pl);
|
|
1165
|
+
unsigned char* __pc = __pw_raw == 0 ? (unsigned char*)malloc(__pl ? __pl : 1) : 0;
|
|
985
1166
|
if (__pc) memcpy(__pc, __p, __pl);
|
|
986
|
-
if (__po) free(__po);
|
|
987
1167
|
|
|
988
|
-
const char* __s; size_t __sl
|
|
989
|
-
|
|
990
|
-
unsigned char* __sc = (unsigned char*)malloc(__sl ? __sl : 1);
|
|
1168
|
+
const char* __s = 0; size_t __sl = 0;
|
|
1169
|
+
int __salt_raw = porf_native_fetch_read_raw_bytes(salt, &__s, &__sl);
|
|
1170
|
+
unsigned char* __sc = __salt_raw == 0 ? (unsigned char*)malloc(__sl ? __sl : 1) : 0;
|
|
991
1171
|
if (__sc) memcpy(__sc, __s, __sl);
|
|
992
|
-
if (__so) free(__so);
|
|
993
1172
|
|
|
994
1173
|
long __N = 0, __r = 0, __pp = 0, __dk = 0;
|
|
995
1174
|
{ const char* q; size_t ql; char* qo = 0; char nb[24];
|
|
@@ -1739,6 +1918,17 @@ function __sbClientIp(request) {
|
|
|
1739
1918
|
}
|
|
1740
1919
|
return peer;
|
|
1741
1920
|
}
|
|
1921
|
+
// #128 — httpProtocol/tlsVersion/tlsCipher: only a trusted front proxy can set
|
|
1922
|
+
// these (an untrusted direct peer could otherwise fake any value), so this
|
|
1923
|
+
// reuses the same SB_TRUSTED_PROXIES chain #163 already resolves clientIp
|
|
1924
|
+
// against. Undefined when no trusted proxy is configured or it sent nothing —
|
|
1925
|
+
// omitted, not faked, matching how clientIp itself falls back to the raw peer.
|
|
1926
|
+
function __sbTrustedProxyHeader(request, name) {
|
|
1927
|
+
const peer = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
|
|
1928
|
+
const trusted = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
|
|
1929
|
+
if (!trusted.length || !__sbIpTrusted(peer, trusted)) return undefined;
|
|
1930
|
+
return request.headers.get(name) || undefined;
|
|
1931
|
+
}
|
|
1742
1932
|
|
|
1743
1933
|
// #57 — ctx.waitUntil: work that must run before the turn completes but must
|
|
1744
1934
|
// not block the response the handler already built. `waitUntil(p)` just
|
|
@@ -1824,9 +2014,18 @@ async function __sbQueueWithDrain(result, tasks) {
|
|
|
1824
2014
|
globalThis.__sbEntry = function (handlers, request) {
|
|
1825
2015
|
const trigger = request.headers.get("x-sb-trigger");
|
|
1826
2016
|
if (!trigger) {
|
|
1827
|
-
// #163 — expose the
|
|
2017
|
+
// #163/#128 — expose the request metadata the edge already has, the
|
|
2018
|
+
// Workers way, before the handler runs. httpProtocol/tlsVersion/tlsCipher
|
|
2019
|
+
// are only set when a trusted front proxy forwarded them; colo, ASN and
|
|
2020
|
+
// geo fields stay absent everywhere (#128 explicitly rejects faking them).
|
|
1828
2021
|
const __cf = request.cf || {};
|
|
1829
2022
|
__cf.clientIp = __sbClientIp(request);
|
|
2023
|
+
const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
|
|
2024
|
+
if (__httpProtocol) __cf.httpProtocol = __httpProtocol;
|
|
2025
|
+
const __tlsVersion = __sbTrustedProxyHeader(request, "x-sb-tls-version");
|
|
2026
|
+
if (__tlsVersion) __cf.tlsVersion = __tlsVersion;
|
|
2027
|
+
const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
|
|
2028
|
+
if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
|
|
1830
2029
|
request.cf = __cf;
|
|
1831
2030
|
// #28 — per-invocation CPU time. One fetch turn per process (serial), so the
|
|
1832
2031
|
// process CPU delta across the handler is this invocation's CPU.
|
|
@@ -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,125 @@
|
|
|
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 { dirname, 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
|
+
const hex = (buffer) => {
|
|
22
|
+
const bytes = new Uint8Array(buffer);
|
|
23
|
+
let out = "";
|
|
24
|
+
for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
export default {
|
|
28
|
+
async fetch() {
|
|
29
|
+
const key = await crypto.subtle.importKey(
|
|
30
|
+
"raw",
|
|
31
|
+
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
|
32
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
33
|
+
false,
|
|
34
|
+
["sign"],
|
|
35
|
+
);
|
|
36
|
+
let data = new TextEncoder().encode("h1-regression-password-0001");
|
|
37
|
+
const rounds = [];
|
|
38
|
+
for (let i = 0; i < 2; i++) {
|
|
39
|
+
data = new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
|
|
40
|
+
rounds.push(hex(data));
|
|
41
|
+
}
|
|
42
|
+
const binary = new Uint8Array([0x00, 0x7f, 0x80, 0xff]);
|
|
43
|
+
const binaryKey = await crypto.subtle.importKey(
|
|
44
|
+
"raw", new Uint8Array([0x80, 0xff]), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
|
45
|
+
);
|
|
46
|
+
const digest = hex(await crypto.subtle.digest("SHA-256", binary));
|
|
47
|
+
const keyed = hex(await crypto.subtle.sign("HMAC", binaryKey, binary));
|
|
48
|
+
const scrypt = crypto.scryptVerify(
|
|
49
|
+
"café",
|
|
50
|
+
binary,
|
|
51
|
+
"4459ed251d645d761e6d111db6bedeb64a8e3e8a9ad2b30dce4550ae9fc35049",
|
|
52
|
+
{ N: 16, r: 1, p: 1 },
|
|
53
|
+
);
|
|
54
|
+
return Response.json({ rounds, digest, keyed, scrypt });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
function freePort(): number {
|
|
60
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
61
|
+
const port = listener.port;
|
|
62
|
+
listener.stop(true);
|
|
63
|
+
return port;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-crypto-"));
|
|
67
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const porffor = await ensurePorffor({ cacheRoot: workdir });
|
|
70
|
+
const esbuild = Bun.which("esbuild");
|
|
71
|
+
const node = Bun.which("node");
|
|
72
|
+
if (!esbuild || !node) throw new Error("native crypto test needs esbuild and 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
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
83
|
+
await rm(binary, { force: true });
|
|
84
|
+
|
|
85
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
86
|
+
cwd: workdir,
|
|
87
|
+
env: { ...process.env, PATH: `${dirname(esbuild)}:${process.env.PATH ?? ""}` },
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
});
|
|
91
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
92
|
+
compile.exited,
|
|
93
|
+
new Response(compile.stdout).text(),
|
|
94
|
+
new Response(compile.stderr).text(),
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
97
|
+
|
|
98
|
+
const port = freePort();
|
|
99
|
+
server = Bun.spawn([binary], {
|
|
100
|
+
cwd: workdir,
|
|
101
|
+
env: { ...process.env, PORT: String(port) },
|
|
102
|
+
stdout: "ignore",
|
|
103
|
+
stderr: "inherit",
|
|
104
|
+
});
|
|
105
|
+
let response: Response | undefined;
|
|
106
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
109
|
+
break;
|
|
110
|
+
} catch {
|
|
111
|
+
if (server.exitCode !== null) break;
|
|
112
|
+
await Bun.sleep(100);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
assert(response, "compiled crypto handler did not start");
|
|
116
|
+
assert.equal(response.status, 200);
|
|
117
|
+
assert.deepEqual(await response.json(), expected);
|
|
118
|
+
console.log("Native crypto vectors match Bun WebCrypto and node:crypto");
|
|
119
|
+
} finally {
|
|
120
|
+
if (server) {
|
|
121
|
+
server.kill(9);
|
|
122
|
+
await server.exited;
|
|
123
|
+
}
|
|
124
|
+
await rm(workdir, { recursive: true, force: true });
|
|
125
|
+
}
|