@sproutboat/runtime 0.34.3 → 0.34.5
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 +13 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +120 -1
- package/src/r2-object.test.js +1 -1
- package/src/transport-embedded.js +32 -12
- package/test/native-d1.ts +2 -2
- package/test/native-http.ts +280 -0
- package/test/native-jwt.ts +333 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.34.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e62b600: The client address is folded to plain IPv4 once per distinct address instead of on every request: a request read `x-sb-remote-addr` and, for an IPv4-mapped IPv6 peer, lowercased, split and hex-parsed it each time. The last address and its result are kept; a different address takes the same path as before.
|
|
8
|
+
- 0b12b94: A strict D1 query hands its rows to JavaScript with fewer copies: the text of every cell goes into one arena instead of one `malloc` per cell, and a cell that is plain ASCII becomes its JS string in one copy (it used to go through a UTF-16 buffer, an 8-bit buffer and a third copy, three `malloc`s each). Non-ASCII text still takes the old path, so the strings are unchanged.
|
|
9
|
+
|
|
10
|
+
## 0.34.4
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 5bd4a25: Cheaper JWT verification. `atob` decodes in C for the usual input (no whitespace, valid characters, correct padding) and falls back to the existing JavaScript for everything else, so what throws and what it throws is unchanged. `TextEncoder.encode` sizes and fills the Uint8Array in two C passes instead of two `charCodeAt` loops (a lone surrogate is still U+FFFD). The ArrayBuffer returned by `crypto.subtle.sign` and `digest` is copied in with one `memcpy`, and the constant-time compare behind `verify` runs in C. A typical `authenticate()` (three base64url decodes, two `JSON.parse`, one `TextEncoder.encode`, one HMAC verify) goes from 21.5 to 7 us on an M-series host. New native test `test:native-jwt`: RFC 4231 HMAC vectors, the RFC 7515 and jwt.io tokens, wrong key, tampered header, payload and signature, short, long and missing signatures, bad base64url, typed-array windows and ArrayBuffers as verify input, and seeded random `atob`, `TextEncoder` and HMAC (SHA-256, 384, 512) inputs compared with Bun and node:crypto.
|
|
15
|
+
|
|
3
16
|
## 0.34.3
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3645,7 +3645,12 @@ if (globalThis.crypto.getRandomValues == null) {
|
|
|
3645
3645
|
for (let i = 0; i < 128; i++) sextet.push(255);
|
|
3646
3646
|
for (let i = 0; i < alphabet.length; i++) sextet[alphabet.charCodeAt(i)] = i;
|
|
3647
3647
|
globalThis.atob = function (data) {
|
|
3648
|
-
|
|
3648
|
+
// One C pass for the usual input (no whitespace, valid characters); the JS below
|
|
3649
|
+
// takes everything else, so it alone decides what throws.
|
|
3650
|
+
const input = String(data);
|
|
3651
|
+
const fast = __sbAtobNative(input);
|
|
3652
|
+
if (fast !== "") return fast;
|
|
3653
|
+
let text = input.replace(/[\t\n\f\r ]/g, "");
|
|
3649
3654
|
if (text.length % 4 === 0) text = text.replace(/==?$/, "");
|
|
3650
3655
|
if (text.length % 4 === 1) throw invalidCharacter("atob: the string is not valid base64");
|
|
3651
3656
|
const parts = [];
|
|
@@ -4058,6 +4063,7 @@ function __sbToBytes(input) {
|
|
|
4058
4063
|
}
|
|
4059
4064
|
function __sbBufFrom(latin1) {
|
|
4060
4065
|
const b = new Uint8Array(latin1.length);
|
|
4066
|
+
if (__sbFillBytes(latin1, b)) return b.buffer;
|
|
4061
4067
|
for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
|
|
4062
4068
|
return b.buffer;
|
|
4063
4069
|
}
|
|
@@ -4190,6 +4196,8 @@ function __sbRawBodyResponse(body, init) {
|
|
|
4190
4196
|
// Constant-time compare of two latin1 byte strings.
|
|
4191
4197
|
function __sbEqCt(a, b) {
|
|
4192
4198
|
if (a.length !== b.length) return false;
|
|
4199
|
+
const native = __sbEqCtNative(a, b);
|
|
4200
|
+
if (native >= 0) return native === 1;
|
|
4193
4201
|
let diff = 0;
|
|
4194
4202
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
4195
4203
|
return diff === 0;
|
|
@@ -4805,6 +4813,21 @@ static size_t sb_utf8_encode_units(const uint16_t* s, size_t len, unsigned char*
|
|
|
4805
4813
|
return k;
|
|
4806
4814
|
}
|
|
4807
4815
|
|
|
4816
|
+
// Unit I of a string held as UTF-16 (WIDE) or one unit per byte.
|
|
4817
|
+
static unsigned sb_unit(const unsigned char* s, int wide, size_t i) {
|
|
4818
|
+
return wide ? (unsigned)((const uint16_t*)s)[i] : (unsigned)s[i];
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
// Base64 sextet of a character, or -1.
|
|
4822
|
+
static int sb_b64_val(unsigned c) {
|
|
4823
|
+
if (c >= 'A' && c <= 'Z') return (int)(c - 'A');
|
|
4824
|
+
if (c >= 'a' && c <= 'z') return (int)(c - 'a') + 26;
|
|
4825
|
+
if (c >= '0' && c <= '9') return (int)(c - '0') + 52;
|
|
4826
|
+
if (c == '+') return 62;
|
|
4827
|
+
if (c == '/') return 63;
|
|
4828
|
+
return -1;
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4808
4831
|
`;
|
|
4809
4832
|
|
|
4810
4833
|
// sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit) or a Uint8Array, in C.
|
|
@@ -4905,6 +4928,91 @@ function __sbToBytesNative(input) {
|
|
|
4905
4928
|
return out;
|
|
4906
4929
|
}
|
|
4907
4930
|
|
|
4931
|
+
// Constant-time equality of two equal-length bytestrings in C: 1 equal, 0 different, -1 when
|
|
4932
|
+
// either is not a bytestring (the caller then compares in JS).
|
|
4933
|
+
// oxlint-disable-next-line no-unused-vars -- `a` and `b` are read inside the RawC block below, not by JS.
|
|
4934
|
+
function __sbEqCtNative(a, b) {
|
|
4935
|
+
let result = -1;
|
|
4936
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4937
|
+
Porffor.c`
|
|
4938
|
+
if (a.type == 195 && b.type == 195) {
|
|
4939
|
+
const size_t __n = (size_t)*(u32*)(MEM + (u32)a.val);
|
|
4940
|
+
if (__n == (size_t)*(u32*)(MEM + (u32)b.val)) {
|
|
4941
|
+
const unsigned char* __x = (const unsigned char*)(MEM + (u32)a.val + 4);
|
|
4942
|
+
const unsigned char* __y = (const unsigned char*)(MEM + (u32)b.val + 4);
|
|
4943
|
+
unsigned char __diff = 0;
|
|
4944
|
+
for (size_t __i = 0; __i < __n; __i++) __diff |= __x[__i] ^ __y[__i];
|
|
4945
|
+
result = (f64)(__diff == 0 ? 1 : 0);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
`;
|
|
4949
|
+
return result;
|
|
4950
|
+
}
|
|
4951
|
+
|
|
4952
|
+
// Copies a bytestring (one unit per byte) into the front of a Uint8Array.
|
|
4953
|
+
// Returns false (writing nothing) when it cannot.
|
|
4954
|
+
// oxlint-disable-next-line no-unused-vars -- `bytes` and `view` are read inside the RawC block below, not by JS.
|
|
4955
|
+
function __sbFillBytes(bytes, view) {
|
|
4956
|
+
let ok = 0;
|
|
4957
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4958
|
+
Porffor.c`
|
|
4959
|
+
if (bytes.type == 195 && view.type == 81 && *(int32_t*)(MEM + (u32)view.val) >= 0) {
|
|
4960
|
+
const u32 __p = (u32)bytes.val;
|
|
4961
|
+
const u32 __v = (u32)view.val;
|
|
4962
|
+
const size_t __n = (size_t)*(u32*)(MEM + __p);
|
|
4963
|
+
if (__n <= (size_t)*(int32_t*)(MEM + __v)) {
|
|
4964
|
+
memcpy(MEM + *(u32*)(MEM + __v + 4) + 4, MEM + __p + 4, __n);
|
|
4965
|
+
ok = (f64)1;
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
`;
|
|
4969
|
+
return ok === 1;
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
// atob for the common case: no whitespace, only base64 characters, `=` padding
|
|
4973
|
+
// only where the spec allows it. Returns the decoded bytestring, or "" for
|
|
4974
|
+
// anything else (including an empty result), so the caller's JS path decides
|
|
4975
|
+
// and throws.
|
|
4976
|
+
// oxlint-disable-next-line no-unused-vars -- `text` is read inside the RawC block below, not by JS.
|
|
4977
|
+
function __sbAtobNative(text) {
|
|
4978
|
+
let out = "";
|
|
4979
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4980
|
+
Porffor.c`
|
|
4981
|
+
if (text.type == 195 || text.type == 67) {
|
|
4982
|
+
const u32 __p = (u32)text.val;
|
|
4983
|
+
const int __wide = text.type == 67;
|
|
4984
|
+
size_t __n = (size_t)*(u32*)(MEM + __p);
|
|
4985
|
+
const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
|
|
4986
|
+
if (__n % 4 == 0) {
|
|
4987
|
+
if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=') __n--;
|
|
4988
|
+
if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=' && __n % 4 != 0) __n--;
|
|
4989
|
+
}
|
|
4990
|
+
if (__n > 0 && __n % 4 != 1) {
|
|
4991
|
+
const size_t __cap = __n / 4 * 3 + 3;
|
|
4992
|
+
unsigned char __stack[768];
|
|
4993
|
+
unsigned char* __o = __cap <= sizeof(__stack) ? __stack : (unsigned char*)malloc(__cap);
|
|
4994
|
+
if (__o) {
|
|
4995
|
+
size_t __k = 0, __i = 0;
|
|
4996
|
+
int __bad = 0;
|
|
4997
|
+
uint32_t __acc = 0;
|
|
4998
|
+
int __bits = 0;
|
|
4999
|
+
for (; __i < __n; __i++) {
|
|
5000
|
+
const unsigned __c = sb_unit(__s, __wide, __i);
|
|
5001
|
+
const int __v = __c < 128 ? sb_b64_val(__c) : -1;
|
|
5002
|
+
if (__v < 0) { __bad = 1; break; }
|
|
5003
|
+
__acc = (__acc << 6) | (uint32_t)__v;
|
|
5004
|
+
__bits += 6;
|
|
5005
|
+
if (__bits >= 8) { __bits -= 8; __o[__k++] = (unsigned char)((__acc >> __bits) & 0xff); }
|
|
5006
|
+
}
|
|
5007
|
+
if (!__bad) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, __k), 195);
|
|
5008
|
+
if (__o != __stack) free(__o);
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
`;
|
|
5013
|
+
return out;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
4908
5016
|
// TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
|
|
4909
5017
|
|
|
4910
5018
|
// `nStr` is the decimal byte count as a string (same string-param pattern as
|
|
@@ -7851,8 +7959,19 @@ function __sbParseHex(s) {
|
|
|
7851
7959
|
// uWS hands back IPv4-mapped IPv6 for IPv4 clients on a dual-stack socket
|
|
7852
7960
|
// (`::ffff:1.2.3.4`, or fully expanded `0:0:0:0:0:ffff:0102:0304`). Fold those
|
|
7853
7961
|
// to the dotted IPv4 so CIDR matching and `clientIp` see a plain address.
|
|
7962
|
+
// The last address folded and its result: a client keeps the same address from one request to the
|
|
7963
|
+
// next, and folding an IPv6 text costs a lowercase, a split and eight hex parses.
|
|
7964
|
+
let __sbIpRaw = "";
|
|
7965
|
+
let __sbIpFolded = "";
|
|
7854
7966
|
function __sbNormalizeIp(ip) {
|
|
7855
7967
|
const s = String(ip || "");
|
|
7968
|
+
if (s === __sbIpRaw) return __sbIpFolded;
|
|
7969
|
+
const folded = __sbFoldIp(s);
|
|
7970
|
+
__sbIpRaw = s;
|
|
7971
|
+
__sbIpFolded = folded;
|
|
7972
|
+
return folded;
|
|
7973
|
+
}
|
|
7974
|
+
function __sbFoldIp(s) {
|
|
7856
7975
|
if (s.indexOf(":") === -1) return s;
|
|
7857
7976
|
const low = s.toLowerCase();
|
|
7858
7977
|
const dotted = low.indexOf("::ffff:");
|
package/src/r2-object.test.js
CHANGED
|
@@ -31,7 +31,7 @@ const load = new Function(
|
|
|
31
31
|
"__sbRpc",
|
|
32
32
|
"__sbR2MultipartPut",
|
|
33
33
|
// __sbIsStr and __sbToBytes (for uploadPart's values), with the C fast paths stubbed out.
|
|
34
|
-
`function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
|
|
34
|
+
`function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\nfunction __sbFillBytes() { return false; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
|
|
35
35
|
);
|
|
36
36
|
const calls = [];
|
|
37
37
|
const rpc = (op, value) => {
|
|
@@ -2162,13 +2162,33 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
2162
2162
|
// A strict D1 query can hand its rows back as cells instead of JSON text: the caller turns
|
|
2163
2163
|
// them into JS arrays, with no JSON written here and none parsed there. t is 0 NULL,
|
|
2164
2164
|
// 1 number, 2 TEXT (UTF-8 in p), 3 BLOB (bytes in p).
|
|
2165
|
-
|
|
2166
|
-
|
|
2165
|
+
// The text and blob bytes of every cell share one arena (a cell holds an offset into it), and a
|
|
2166
|
+
// TEXT cell says whether it is all ASCII: that one becomes a JS string with a single copy.
|
|
2167
|
+
typedef struct { unsigned char t; unsigned char ascii; double d; size_t off; int n; } sb_cell;
|
|
2168
|
+
typedef struct { sb_cell* c; size_t n; size_t cap; int ncol; int used; char* a; size_t alen; size_t acap; } sb_cells;
|
|
2167
2169
|
static void sb_cells_free(sb_cells* cs) {
|
|
2168
2170
|
if (!cs) return;
|
|
2169
|
-
for (size_t i = 0; i < cs->n; i++) free(cs->c[i].p);
|
|
2170
2171
|
free(cs->c);
|
|
2171
|
-
cs->
|
|
2172
|
+
free(cs->a);
|
|
2173
|
+
cs->c = 0; cs->a = 0; cs->n = cs->cap = cs->alen = cs->acap = 0;
|
|
2174
|
+
}
|
|
2175
|
+
// Copies n bytes into the arena and returns their offset, or (size_t)-1 when out of memory.
|
|
2176
|
+
static size_t sb_cells_bytes(sb_cells* cs, const unsigned char* src, size_t n, unsigned char* ascii) {
|
|
2177
|
+
if (cs->alen + n + 1 > cs->acap) {
|
|
2178
|
+
size_t cap = cs->acap ? cs->acap * 2 : 1024;
|
|
2179
|
+
while (cap < cs->alen + n + 1) cap *= 2;
|
|
2180
|
+
char* grown = (char*)realloc(cs->a, cap);
|
|
2181
|
+
if (!grown) return (size_t)-1;
|
|
2182
|
+
cs->a = grown; cs->acap = cap;
|
|
2183
|
+
}
|
|
2184
|
+
const size_t off = cs->alen;
|
|
2185
|
+
unsigned char high = 0;
|
|
2186
|
+
for (size_t i = 0; i < n; i++) high |= src[i];
|
|
2187
|
+
if (n) memcpy(cs->a + off, src, n);
|
|
2188
|
+
cs->a[off + n] = 0;
|
|
2189
|
+
cs->alen = off + n + 1;
|
|
2190
|
+
*ascii = high < 0x80;
|
|
2191
|
+
return off;
|
|
2172
2192
|
}
|
|
2173
2193
|
static sb_cell* sb_cells_add(sb_cells* cs) {
|
|
2174
2194
|
if (cs->n == cs->cap) {
|
|
@@ -2178,7 +2198,7 @@ static sb_cell* sb_cells_add(sb_cells* cs) {
|
|
|
2178
2198
|
cs->c = grown; cs->cap = cap;
|
|
2179
2199
|
}
|
|
2180
2200
|
sb_cell* cell = &cs->c[cs->n++];
|
|
2181
|
-
cell->t = 0; cell->d = 0; cell->
|
|
2201
|
+
cell->t = 0; cell->ascii = 0; cell->d = 0; cell->off = 0; cell->n = 0;
|
|
2182
2202
|
return cell;
|
|
2183
2203
|
}
|
|
2184
2204
|
static char* sb_sql_run_x(const char* path, const char* sql, const char* params, sb_cells* cells) {
|
|
@@ -2226,10 +2246,8 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
|
|
|
2226
2246
|
int n = sqlite3_column_bytes(st, i);
|
|
2227
2247
|
cell->t = t == 4 ? 3 : 2;
|
|
2228
2248
|
cell->n = txt ? n : 0;
|
|
2229
|
-
cell->
|
|
2230
|
-
if (
|
|
2231
|
-
if (cell->n) memcpy(cell->p, txt, (size_t)cell->n);
|
|
2232
|
-
cell->p[cell->n] = 0;
|
|
2249
|
+
cell->off = sb_cells_bytes(cells, txt, (size_t)cell->n, &cell->ascii);
|
|
2250
|
+
if (cell->off == (size_t)-1) { cells->used = -1; break; }
|
|
2233
2251
|
}
|
|
2234
2252
|
if (cells->used < 0) break;
|
|
2235
2253
|
continue;
|
|
@@ -4261,12 +4279,14 @@ function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
|
4261
4279
|
const sb_cell* __cell = &__cells.c[__r * __ncol + __c];
|
|
4262
4280
|
jsval __v = porf_box(0.0, 7);
|
|
4263
4281
|
if (__cell->t == 1) __v = porf_box_num(__cell->d);
|
|
4264
|
-
else if (__cell->t == 2) {
|
|
4282
|
+
else if (__cell->t == 2 && __cell->ascii) {
|
|
4283
|
+
__v = porf_box((f64)porf_native_fetch_alloc_bytestring(__cells.a + __cell->off, (size_t)__cell->n), 195);
|
|
4284
|
+
} else if (__cell->t == 2) {
|
|
4265
4285
|
const size_t __n = (size_t)__cell->n;
|
|
4266
4286
|
uint16_t* __u = (uint16_t*)malloc(__n * 2 + 2);
|
|
4267
4287
|
if (__u) {
|
|
4268
4288
|
int __wide = 0;
|
|
4269
|
-
const size_t __k = sb_text_units((const unsigned char*)__cell->
|
|
4289
|
+
const size_t __k = sb_text_units((const unsigned char*)(__cells.a + __cell->off), __n, __u, &__wide);
|
|
4270
4290
|
if (__wide) {
|
|
4271
4291
|
const u32 __str = porf_native_fetch_alloc((u32)(4 + __k * 2), 67);
|
|
4272
4292
|
*(u32*)(MEM + __str) = (u32)__k;
|
|
@@ -4284,7 +4304,7 @@ function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
|
4284
4304
|
}
|
|
4285
4305
|
} else if (__cell->t == 3) {
|
|
4286
4306
|
const u32 __bytes = porf_arr_new((i32)__cell->n, (i32)__cell->n);
|
|
4287
|
-
for (int __i = 0; __i < __cell->n; __i++) porf_arr_set(__bytes, (u32)__i, porf_box_num((f64)(unsigned char)__cell->
|
|
4307
|
+
for (int __i = 0; __i < __cell->n; __i++) porf_arr_set(__bytes, (u32)__i, porf_box_num((f64)(unsigned char)__cells.a[__cell->off + __i]));
|
|
4288
4308
|
__v = porf_box((f64)__bytes, 72);
|
|
4289
4309
|
}
|
|
4290
4310
|
porf_arr_set(__row, (u32)__c, __v);
|
package/test/native-d1.ts
CHANGED
|
@@ -67,12 +67,12 @@ int main(void) {
|
|
|
67
67
|
else if (x->t == 1) printf("%.17g", x->d);
|
|
68
68
|
else if (x->t == 2) {
|
|
69
69
|
uint16_t* u = (uint16_t*)malloc((size_t)x->n * 2 + 2); int wide = 0;
|
|
70
|
-
size_t k = sb_text_units((const unsigned char*)x->
|
|
70
|
+
size_t k = sb_text_units((const unsigned char*)(cs.a + x->off), (size_t)x->n, u, &wide);
|
|
71
71
|
printf("\\"");
|
|
72
72
|
for (size_t i = 0; i < k; i++) printf("\\\\u%04x", u[i]);
|
|
73
73
|
printf("\\"");
|
|
74
74
|
free(u);
|
|
75
|
-
} else { printf("{\\"$blob\\":\\""); for (int i = 0; i < x->n; i++) printf("%02x", (unsigned char)x->
|
|
75
|
+
} else { printf("{\\"$blob\\":\\""); for (int i = 0; i < x->n; i++) printf("%02x", (unsigned char)cs.a[x->off + i]); printf("\\"}"); }
|
|
76
76
|
}
|
|
77
77
|
printf("]");
|
|
78
78
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
8
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
9
|
+
|
|
10
|
+
// The native HTTP server's wire behaviour with raw sockets: keep-alive, pipelining (responses in
|
|
11
|
+
// order, also when a request arrives in pieces), a response bigger than the socket buffer to a slow
|
|
12
|
+
// reader (partial writes), a large request body, and many connections at once. Run once with a
|
|
13
|
+
// collection after every request (SB_GC_EVERY=1) and once with the default interval, so the
|
|
14
|
+
// collection schedule is shown not to disturb any of it.
|
|
15
|
+
const handler = `export default {async fetch(request) {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const path = url.pathname;
|
|
18
|
+
if (path.startsWith("/n/")) return new Response("n=" + path.slice(3), { headers: { "x-n": path.slice(3) } });
|
|
19
|
+
if (path === "/echo") { const b = await request.arrayBuffer(); return new Response("len=" + b.byteLength + " first=" + (b.byteLength ? new Uint8Array(b)[0] : -1) + " last=" + (b.byteLength ? new Uint8Array(b)[b.byteLength - 1] : -1)); }
|
|
20
|
+
if (path === "/big") {
|
|
21
|
+
const size = Number(url.searchParams.get("size"));
|
|
22
|
+
const out = new Uint8Array(size);
|
|
23
|
+
for (let i = 0; i < size; i++) out[i] = (i * 31 + 7) & 255;
|
|
24
|
+
return new Response(out);
|
|
25
|
+
}
|
|
26
|
+
if (path === "/garbage") { const rows = []; for (let i = 0; i < 2000; i++) rows.push({ i, s: "x".repeat(i % 50) }); return new Response(JSON.stringify(rows).length + ""); }
|
|
27
|
+
return new Response("not found", { status: 404 });
|
|
28
|
+
}};`;
|
|
29
|
+
|
|
30
|
+
function freePort(): number {
|
|
31
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
32
|
+
const port = listener.port;
|
|
33
|
+
listener.stop(true);
|
|
34
|
+
return port;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Parsed = { status: number; headers: Map<string, string>; body: Buffer };
|
|
38
|
+
|
|
39
|
+
// Parses as many whole Content-Length responses as `buffer` holds; returns them and the rest.
|
|
40
|
+
type ParsedBatch = { done: Parsed[]; rest: Buffer };
|
|
41
|
+
function parseResponses(buffer: Buffer): ParsedBatch {
|
|
42
|
+
const done: Parsed[] = [];
|
|
43
|
+
let rest = buffer;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const end = rest.indexOf("\r\n\r\n");
|
|
46
|
+
if (end < 0) break;
|
|
47
|
+
const head = rest.subarray(0, end).toString("latin1").split("\r\n");
|
|
48
|
+
const status = Number(head[0].split(" ")[1]);
|
|
49
|
+
const headers = new Map<string, string>();
|
|
50
|
+
for (const line of head.slice(1)) {
|
|
51
|
+
const at = line.indexOf(":");
|
|
52
|
+
headers.set(line.slice(0, at).toLowerCase(), line.slice(at + 1).trim());
|
|
53
|
+
}
|
|
54
|
+
const length = Number(headers.get("content-length") ?? 0);
|
|
55
|
+
if (rest.length < end + 4 + length) break;
|
|
56
|
+
done.push({ status, headers, body: rest.subarray(end + 4, end + 4 + length) });
|
|
57
|
+
rest = rest.subarray(end + 4 + length);
|
|
58
|
+
}
|
|
59
|
+
return { done, rest };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Writes `chunks` (with `gapMs` between them), then reads until `want` responses arrived.
|
|
63
|
+
// `readDelayMs` holds the socket paused first, so the server's send buffer fills.
|
|
64
|
+
async function exchange(
|
|
65
|
+
port: number,
|
|
66
|
+
chunks: Buffer[],
|
|
67
|
+
want: number,
|
|
68
|
+
opts: { gapMs?: number; readDelayMs?: number; timeoutMs?: number } = {},
|
|
69
|
+
): Promise<Parsed[]> {
|
|
70
|
+
return new Promise((resolveExchange, reject) => {
|
|
71
|
+
const socket = connect(port, "127.0.0.1");
|
|
72
|
+
let pending = Buffer.alloc(0);
|
|
73
|
+
const responses: Parsed[] = [];
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
reject(new Error(`timed out with ${responses.length} of ${want} responses`));
|
|
77
|
+
}, opts.timeoutMs ?? 30_000);
|
|
78
|
+
socket.on("error", reject);
|
|
79
|
+
socket.on("data", (data) => {
|
|
80
|
+
pending = Buffer.concat([pending, data]);
|
|
81
|
+
const { done, rest } = parseResponses(pending);
|
|
82
|
+
pending = rest;
|
|
83
|
+
responses.push(...done);
|
|
84
|
+
if (responses.length >= want) {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
socket.destroy();
|
|
87
|
+
resolveExchange(responses);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
socket.on("connect", async () => {
|
|
91
|
+
if (opts.readDelayMs) {
|
|
92
|
+
socket.pause();
|
|
93
|
+
setTimeout(() => socket.resume(), opts.readDelayMs);
|
|
94
|
+
}
|
|
95
|
+
for (const chunk of chunks) {
|
|
96
|
+
socket.write(chunk);
|
|
97
|
+
if (opts.gapMs) await Bun.sleep(opts.gapMs);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const get = (path: string, close = false) =>
|
|
104
|
+
Buffer.from(`GET ${path} HTTP/1.1\r\nHost: t\r\n${close ? "Connection: close\r\n" : ""}\r\n`);
|
|
105
|
+
const post = (path: string, body: Buffer) =>
|
|
106
|
+
Buffer.concat([Buffer.from(`POST ${path} HTTP/1.1\r\nHost: t\r\nContent-Length: ${body.length}\r\n\r\n`), body]);
|
|
107
|
+
|
|
108
|
+
async function suite(port: number, label: string) {
|
|
109
|
+
// Keep-alive: sequential requests on one connection, each answered before the next is sent.
|
|
110
|
+
{
|
|
111
|
+
const socket = connect(port, "127.0.0.1");
|
|
112
|
+
await new Promise<void>((r) => socket.once("connect", () => r()));
|
|
113
|
+
let pending = Buffer.alloc(0);
|
|
114
|
+
for (let i = 0; i < 5; i++) {
|
|
115
|
+
socket.write(get(`/n/${i}`));
|
|
116
|
+
let got: Parsed | undefined;
|
|
117
|
+
while (!got) {
|
|
118
|
+
const data = await new Promise<Buffer>((r) => socket.once("data", r));
|
|
119
|
+
pending = Buffer.concat([pending, data]);
|
|
120
|
+
const parsed = parseResponses(pending);
|
|
121
|
+
pending = parsed.rest;
|
|
122
|
+
got = parsed.done[0];
|
|
123
|
+
}
|
|
124
|
+
assert.equal(got.body.toString(), `n=${i}`, `${label}: keep-alive response ${i}`);
|
|
125
|
+
}
|
|
126
|
+
socket.destroy();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pipelining: 60 requests in one write come back complete and in order.
|
|
130
|
+
{
|
|
131
|
+
const all = Buffer.concat(Array.from({ length: 60 }, (_, i) => get(`/n/${i}`)));
|
|
132
|
+
const got = await exchange(port, [all], 60);
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
got.map((r) => r.body.toString()),
|
|
135
|
+
Array.from({ length: 60 }, (_, i) => `n=${i}`),
|
|
136
|
+
`${label}: pipelined order`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pipelining with requests cut at every awkward place: one byte at a time for the first two.
|
|
141
|
+
{
|
|
142
|
+
const bytes = Buffer.concat([get("/n/a"), post("/echo", Buffer.from("hello")), get("/n/b")]);
|
|
143
|
+
const chunks: Buffer[] = [];
|
|
144
|
+
for (let i = 0; i < 40; i++) chunks.push(bytes.subarray(i, i + 1));
|
|
145
|
+
chunks.push(bytes.subarray(40));
|
|
146
|
+
const got = await exchange(port, chunks, 3, { gapMs: 2 });
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
got.map((r) => r.body.toString()),
|
|
149
|
+
["n=a", "len=5 first=104 last=111", "n=b"],
|
|
150
|
+
`${label}: pipelined requests split across packets`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A POST whose body arrives in pieces, followed by a pipelined GET.
|
|
155
|
+
{
|
|
156
|
+
const body = Buffer.alloc(300_000, 0x61);
|
|
157
|
+
body[0] = 0x62;
|
|
158
|
+
const request = Buffer.concat([post("/echo", body), get("/n/after")]);
|
|
159
|
+
const chunks: Buffer[] = [];
|
|
160
|
+
for (let at = 0; at < request.length; at += 40_000) chunks.push(request.subarray(at, at + 40_000));
|
|
161
|
+
const got = await exchange(port, chunks, 2, { gapMs: 3 });
|
|
162
|
+
assert.equal(got[0].body.toString(), "len=300000 first=98 last=97", `${label}: large request body`);
|
|
163
|
+
assert.equal(got[1].body.toString(), "n=after", `${label}: request after a large body`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Partial writes: 12 MB to a reader that does not read for 400 ms, then a pipelined small response
|
|
167
|
+
// that must still arrive after it, in order.
|
|
168
|
+
{
|
|
169
|
+
const size = 12 * 1024 * 1024;
|
|
170
|
+
const got = await exchange(port, [get(`/big?size=${size}`), get("/n/last")], 2, {
|
|
171
|
+
readDelayMs: 400,
|
|
172
|
+
timeoutMs: 60_000,
|
|
173
|
+
});
|
|
174
|
+
assert.equal(got[0].body.length, size, `${label}: big body length`);
|
|
175
|
+
const expected = Buffer.alloc(size);
|
|
176
|
+
for (let i = 0; i < size; i++) expected[i] = (i * 31 + 7) & 255;
|
|
177
|
+
assert.equal(
|
|
178
|
+
createHash("sha256").update(got[0].body).digest("hex"),
|
|
179
|
+
createHash("sha256").update(expected).digest("hex"),
|
|
180
|
+
`${label}: big body bytes`,
|
|
181
|
+
);
|
|
182
|
+
assert.equal(got[1].body.toString(), "n=last", `${label}: response after a partial write`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Connection: close ends the connection after the response.
|
|
186
|
+
{
|
|
187
|
+
const got = await exchange(port, [get("/n/close", true)], 1);
|
|
188
|
+
assert.equal(got[0].body.toString(), "n=close");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 150 connections at once, 4 pipelined requests each, with garbage-heavy requests mixed in.
|
|
192
|
+
{
|
|
193
|
+
const results = await Promise.all(
|
|
194
|
+
Array.from({ length: 150 }, async (_, c) => {
|
|
195
|
+
const chunks = [get(`/n/${c}-0`), get("/garbage"), get(`/n/${c}-2`), get(`/n/${c}-3`)];
|
|
196
|
+
return exchange(port, [Buffer.concat(chunks)], 4);
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
results.forEach((got, c) => {
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
[got[0].body.toString(), got[2].body.toString(), got[3].body.toString()],
|
|
202
|
+
[`n=${c}-0`, `n=${c}-2`, `n=${c}-3`],
|
|
203
|
+
`${label}: connection ${c}`,
|
|
204
|
+
);
|
|
205
|
+
assert.equal(got[1].status, 200);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-http-"));
|
|
211
|
+
const servers: Array<ReturnType<typeof Bun.spawn>> = [];
|
|
212
|
+
try {
|
|
213
|
+
const porffor = await ensurePorffor();
|
|
214
|
+
const node = Bun.which("node");
|
|
215
|
+
if (!node) throw new Error("native http test needs node on PATH");
|
|
216
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
217
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
218
|
+
const generated = join(workdir, "http.generated.js");
|
|
219
|
+
const binary = join(workdir, "http-native");
|
|
220
|
+
await writeFile(
|
|
221
|
+
generated,
|
|
222
|
+
wrapNativeFetchHandler(
|
|
223
|
+
handler,
|
|
224
|
+
core.replace(TRANSPORT_MARKER, broker),
|
|
225
|
+
undefined,
|
|
226
|
+
undefined,
|
|
227
|
+
undefined,
|
|
228
|
+
"2026-10-21",
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
const compile = Bun.spawn(
|
|
232
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
233
|
+
{
|
|
234
|
+
cwd: workdir,
|
|
235
|
+
env: process.env,
|
|
236
|
+
stdout: "pipe",
|
|
237
|
+
stderr: "pipe",
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
241
|
+
compile.exited,
|
|
242
|
+
new Response(compile.stdout).text(),
|
|
243
|
+
new Response(compile.stderr).text(),
|
|
244
|
+
]);
|
|
245
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
246
|
+
|
|
247
|
+
for (const every of ["1", "8"]) {
|
|
248
|
+
const port = freePort();
|
|
249
|
+
const server = Bun.spawn([binary], {
|
|
250
|
+
cwd: workdir,
|
|
251
|
+
env: { ...process.env, PORT: String(port), SB_GC_EVERY: every },
|
|
252
|
+
stdout: "ignore",
|
|
253
|
+
stderr: "inherit",
|
|
254
|
+
});
|
|
255
|
+
servers.push(server);
|
|
256
|
+
let ready = false;
|
|
257
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
258
|
+
try {
|
|
259
|
+
await fetch(`http://127.0.0.1:${port}/n/0`, { signal: AbortSignal.timeout(500) });
|
|
260
|
+
ready = true;
|
|
261
|
+
} catch {
|
|
262
|
+
if (server.exitCode !== null) break;
|
|
263
|
+
await Bun.sleep(100);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
assert(ready, "compiled handler did not start");
|
|
267
|
+
await suite(port, `SB_GC_EVERY=${every}`);
|
|
268
|
+
// The server is still answering after all of it.
|
|
269
|
+
assert.equal(await (await fetch(`http://127.0.0.1:${port}/n/z`)).text(), "n=z");
|
|
270
|
+
}
|
|
271
|
+
console.log(
|
|
272
|
+
"Native HTTP: keep-alive, pipelining, split requests, large bodies, partial writes and 150 connections hold at SB_GC_EVERY=1 and 8",
|
|
273
|
+
);
|
|
274
|
+
} finally {
|
|
275
|
+
for (const server of servers) {
|
|
276
|
+
server.kill(9);
|
|
277
|
+
await server.exited;
|
|
278
|
+
}
|
|
279
|
+
await rm(workdir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
7
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
8
|
+
|
|
9
|
+
// The pieces of a JWT check (base64url decode, TextEncoder, HMAC verify) against values that do
|
|
10
|
+
// not come from the runtime under test: RFC 4231 and RFC 7515 vectors, the jwt.io example, and
|
|
11
|
+
// Bun / node:crypto on seeded random inputs.
|
|
12
|
+
|
|
13
|
+
let seed = 0x2545f491;
|
|
14
|
+
const rand = (n: number): number => {
|
|
15
|
+
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
|
16
|
+
return seed % n;
|
|
17
|
+
};
|
|
18
|
+
const randomBytes = (n: number): Uint8Array => Uint8Array.from({ length: n }, () => rand(256));
|
|
19
|
+
const toB64Url = (b: Uint8Array): string => Buffer.from(b).toString("base64url");
|
|
20
|
+
|
|
21
|
+
// atob: valid, padded and unpadded, with whitespace, and the inputs it must reject.
|
|
22
|
+
const atobInputs: string[] = [];
|
|
23
|
+
for (let n = 0; n <= 40; n++) {
|
|
24
|
+
const padded = Buffer.from(randomBytes(n)).toString("base64");
|
|
25
|
+
atobInputs.push(padded, padded.replace(/=+$/, ""), padded.replace(/(.{3})/, "$1 \n"), ` ${padded}\t`);
|
|
26
|
+
}
|
|
27
|
+
atobInputs.push(
|
|
28
|
+
Buffer.from(randomBytes(5000)).toString("base64"),
|
|
29
|
+
Buffer.from(randomBytes(769)).toString("base64"),
|
|
30
|
+
"a",
|
|
31
|
+
"abcde",
|
|
32
|
+
"ab$c",
|
|
33
|
+
"=abc",
|
|
34
|
+
"ab=c",
|
|
35
|
+
"a===",
|
|
36
|
+
"====",
|
|
37
|
+
"==",
|
|
38
|
+
"abc==",
|
|
39
|
+
"YQ=",
|
|
40
|
+
"YQ===",
|
|
41
|
+
"YQ==YQ==",
|
|
42
|
+
" ",
|
|
43
|
+
"\n",
|
|
44
|
+
"",
|
|
45
|
+
"-_",
|
|
46
|
+
"abéd",
|
|
47
|
+
"abŁd",
|
|
48
|
+
"QUJDé",
|
|
49
|
+
"Ł",
|
|
50
|
+
"YWJj\0",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// TextEncoder: each length, every kind of unit, lone surrogates in every position.
|
|
54
|
+
const pieces = ["a", "Z", "é", "ÿ", "Ā", "߿", "ࠀ", "东", "🚤", "\ud800", "\udc00", "\udbff", "\udfff", "�", "\0"];
|
|
55
|
+
const encodeInputs: string[] = [];
|
|
56
|
+
for (let n = 0; n <= 60; n++) encodeInputs.push(Array.from({ length: n }, () => pieces[rand(pieces.length)]).join(""));
|
|
57
|
+
encodeInputs.push(
|
|
58
|
+
"\ud83d",
|
|
59
|
+
"\ud83dx",
|
|
60
|
+
"x\udea4",
|
|
61
|
+
"\udea4\ud83d",
|
|
62
|
+
"\ud83d🚤",
|
|
63
|
+
"🚤\udea4",
|
|
64
|
+
"é".repeat(3000),
|
|
65
|
+
"🚤".repeat(5000),
|
|
66
|
+
"x".repeat(70000),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// HMAC: every key and message length around the SHA-256 and SHA-512 block sizes, high bytes included.
|
|
70
|
+
const hmacCases: { key: number[]; data: number[]; hash: string }[] = [];
|
|
71
|
+
for (const [klen, dlen] of [
|
|
72
|
+
[0, 0],
|
|
73
|
+
[1, 1],
|
|
74
|
+
[20, 8],
|
|
75
|
+
[32, 55],
|
|
76
|
+
[64, 56],
|
|
77
|
+
[64, 64],
|
|
78
|
+
[65, 119],
|
|
79
|
+
[131, 54],
|
|
80
|
+
[200, 300],
|
|
81
|
+
[16, 1000],
|
|
82
|
+
]) {
|
|
83
|
+
for (const hash of ["SHA-256", "SHA-384", "SHA-512"]) {
|
|
84
|
+
hmacCases.push({ key: Array.from(randomBytes(klen)), data: Array.from(randomBytes(dlen)), hash });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const nodeHash = (h: string): string => h.replace("-", "").toLowerCase();
|
|
88
|
+
const hexOf = (b: Uint8Array | Buffer): string => Buffer.from(b).toString("hex");
|
|
89
|
+
|
|
90
|
+
// Published vectors.
|
|
91
|
+
const rfc4231 = [
|
|
92
|
+
{
|
|
93
|
+
key: "0b".repeat(20),
|
|
94
|
+
data: Buffer.from("Hi There").toString("hex"),
|
|
95
|
+
mac: "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: Buffer.from("Jefe").toString("hex"),
|
|
99
|
+
data: Buffer.from("what do ya want for nothing?").toString("hex"),
|
|
100
|
+
mac: "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: "aa".repeat(131),
|
|
104
|
+
data: Buffer.from("Test Using Larger Than Block-Size Key - Hash Key First").toString("hex"),
|
|
105
|
+
mac: "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
const rfc7515Key = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow";
|
|
109
|
+
const rfc7515 =
|
|
110
|
+
"eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
|
111
|
+
const jwtIo =
|
|
112
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
|
113
|
+
const jwtIoKey = "your-256-bit-secret";
|
|
114
|
+
|
|
115
|
+
const handler = `
|
|
116
|
+
const codes = (s) => Array.from(s, (c) => c.charCodeAt(0));
|
|
117
|
+
const hex = (buffer) => {
|
|
118
|
+
const bytes = new Uint8Array(buffer);
|
|
119
|
+
let out = "";
|
|
120
|
+
for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
|
|
121
|
+
return out;
|
|
122
|
+
};
|
|
123
|
+
const fromHex = (h) => {
|
|
124
|
+
const out = new Uint8Array(h.length / 2);
|
|
125
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
const b64urlBytes = (s) => {
|
|
129
|
+
let b = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
130
|
+
while (b.length % 4) b += "=";
|
|
131
|
+
const bin = atob(b);
|
|
132
|
+
const out = new Uint8Array(bin.length);
|
|
133
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
134
|
+
return out;
|
|
135
|
+
};
|
|
136
|
+
const hmacKey = (bytes, hash, usages) => crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash }, false, usages || ["sign", "verify"]);
|
|
137
|
+
// The check an API server writes: split, decode the signature, verify over the signing input.
|
|
138
|
+
const verifyJwt = async (token, keyBytes) => {
|
|
139
|
+
const dot = token.lastIndexOf(".");
|
|
140
|
+
const key = await hmacKey(keyBytes, "SHA-256", ["verify"]);
|
|
141
|
+
let sig;
|
|
142
|
+
try { sig = b64urlBytes(token.slice(dot + 1)); } catch (e) { return "bad-base64:" + e.name; }
|
|
143
|
+
return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(token.slice(0, dot)));
|
|
144
|
+
};
|
|
145
|
+
export default {
|
|
146
|
+
async fetch() {
|
|
147
|
+
const input = JSON.parse(${JSON.stringify(JSON.stringify({ atobInputs, encodeInputs, hmacCases, rfc4231, rfc7515Key, rfc7515, jwtIo, jwtIoKey }))});
|
|
148
|
+
const out = {};
|
|
149
|
+
out.atob = input.atobInputs.map((s) => { try { return codes(atob(s)); } catch (e) { return e.name; } });
|
|
150
|
+
// The same strings as UTF-16 storage: built from a JSON escape, so not literals.
|
|
151
|
+
out.atobWide = input.atobInputs.slice(0, 12).map((s) => { const w = JSON.parse(JSON.stringify(s + "\\u0141")); try { return codes(atob(w.slice(0, -1))); } catch (e) { return e.name; } });
|
|
152
|
+
out.encode = input.encodeInputs.map((s) => Array.from(new TextEncoder().encode(s)));
|
|
153
|
+
out.encodeWide = input.encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(JSON.parse(JSON.stringify(s)))));
|
|
154
|
+
out.hmac = [];
|
|
155
|
+
for (const c of input.hmacCases) {
|
|
156
|
+
const key = await hmacKey(Uint8Array.from(c.key), c.hash);
|
|
157
|
+
const mac = await crypto.subtle.sign("HMAC", key, Uint8Array.from(c.data));
|
|
158
|
+
const ok = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data));
|
|
159
|
+
const bad = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data.concat([0])));
|
|
160
|
+
out.hmac.push({ mac: hex(mac), ok, bad });
|
|
161
|
+
}
|
|
162
|
+
out.rfc4231 = [];
|
|
163
|
+
for (const v of input.rfc4231) {
|
|
164
|
+
const key = await hmacKey(fromHex(v.key), "SHA-256");
|
|
165
|
+
out.rfc4231.push(hex(await crypto.subtle.sign("HMAC", key, fromHex(v.data))));
|
|
166
|
+
}
|
|
167
|
+
const sigOf = (token) => token.slice(token.lastIndexOf(".") + 1);
|
|
168
|
+
const flip = (token, at) => token.slice(0, at) + (token[at] === "A" ? "B" : "A") + token.slice(at + 1);
|
|
169
|
+
const k1 = b64urlBytes(input.rfc7515Key);
|
|
170
|
+
const k2 = new TextEncoder().encode(input.jwtIoKey);
|
|
171
|
+
out.jwt = {
|
|
172
|
+
rfc7515: await verifyJwt(input.rfc7515, k1),
|
|
173
|
+
jwtIo: await verifyJwt(input.jwtIo, k2),
|
|
174
|
+
wrongKey: await verifyJwt(input.jwtIo, new TextEncoder().encode("your-256-bit-secreT")),
|
|
175
|
+
emptyKey: await verifyJwt(input.jwtIo, new Uint8Array(0)),
|
|
176
|
+
tamperedSignature: await verifyJwt(flip(input.jwtIo, input.jwtIo.length - 3), k2),
|
|
177
|
+
tamperedPayload: await verifyJwt(flip(input.jwtIo, 40), k2),
|
|
178
|
+
tamperedHeader: await verifyJwt(flip(input.jwtIo, 5), k2),
|
|
179
|
+
shortSignature: await verifyJwt(input.jwtIo.slice(0, -4), k2),
|
|
180
|
+
longSignature: await verifyJwt(input.jwtIo + "AAAA", k2),
|
|
181
|
+
noSignature: await verifyJwt(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".") + 1), k2),
|
|
182
|
+
badBase64url: await verifyJwt(input.jwtIo.slice(0, -2) + "\\u0021\\u0021", k2),
|
|
183
|
+
badBase64urlLength: await verifyJwt(input.jwtIo.slice(0, -3), k2),
|
|
184
|
+
};
|
|
185
|
+
// Typed-array windows and ArrayBuffers as verify input, not only whole Uint8Arrays.
|
|
186
|
+
const key = await hmacKey(k2, "SHA-256", ["verify"]);
|
|
187
|
+
const signingInput = new TextEncoder().encode(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".")));
|
|
188
|
+
const sig = b64urlBytes(sigOf(input.jwtIo));
|
|
189
|
+
const padded = new Uint8Array(signingInput.length + 7);
|
|
190
|
+
padded.set(signingInput, 3);
|
|
191
|
+
const sigPadded = new Uint8Array(sig.length + 5);
|
|
192
|
+
sigPadded.set(sig, 2);
|
|
193
|
+
out.views = {
|
|
194
|
+
arrayBuffer: await crypto.subtle.verify("HMAC", key, sig.buffer, signingInput.buffer),
|
|
195
|
+
window: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(2, 34), padded.subarray(3, 3 + signingInput.length)),
|
|
196
|
+
wrongWindow: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(1, 33), padded.subarray(3, 3 + signingInput.length)),
|
|
197
|
+
string: await crypto.subtle.verify("HMAC", key, sig, input.jwtIo.slice(0, input.jwtIo.lastIndexOf("."))),
|
|
198
|
+
};
|
|
199
|
+
out.keyInfo = { type: key.type, extractable: key.extractable, usages: key.usages, algorithm: key.algorithm };
|
|
200
|
+
return Response.json(out);
|
|
201
|
+
},
|
|
202
|
+
};`;
|
|
203
|
+
|
|
204
|
+
function freePort(): number {
|
|
205
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
206
|
+
const port = listener.port;
|
|
207
|
+
listener.stop(true);
|
|
208
|
+
return port;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const codes = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
|
|
212
|
+
const expectedAtob = (s: string): number[] | string => {
|
|
213
|
+
try {
|
|
214
|
+
return codes(atob(s));
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return e instanceof Error ? e.name : "unknown";
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const expected = {
|
|
220
|
+
atob: atobInputs.map(expectedAtob),
|
|
221
|
+
atobWide: atobInputs.slice(0, 12).map((s) => expectedAtob(s)),
|
|
222
|
+
encode: encodeInputs.map((s) => Array.from(new TextEncoder().encode(s))),
|
|
223
|
+
encodeWide: encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(s))),
|
|
224
|
+
hmac: hmacCases.map((c) => ({
|
|
225
|
+
mac: hexOf(createHmac(nodeHash(c.hash), Buffer.from(c.key)).update(Buffer.from(c.data)).digest()),
|
|
226
|
+
ok: true,
|
|
227
|
+
bad: false,
|
|
228
|
+
})),
|
|
229
|
+
rfc4231: rfc4231.map((v) => v.mac),
|
|
230
|
+
jwt: {
|
|
231
|
+
rfc7515: true,
|
|
232
|
+
jwtIo: true,
|
|
233
|
+
wrongKey: false,
|
|
234
|
+
emptyKey: false,
|
|
235
|
+
tamperedSignature: false,
|
|
236
|
+
tamperedPayload: false,
|
|
237
|
+
tamperedHeader: false,
|
|
238
|
+
shortSignature: false,
|
|
239
|
+
longSignature: false,
|
|
240
|
+
noSignature: false,
|
|
241
|
+
badBase64url: "bad-base64:InvalidCharacterError",
|
|
242
|
+
badBase64urlLength: false,
|
|
243
|
+
},
|
|
244
|
+
views: { arrayBuffer: true, window: true, wrongWindow: false, string: true },
|
|
245
|
+
keyInfo: {
|
|
246
|
+
type: "secret",
|
|
247
|
+
extractable: false,
|
|
248
|
+
usages: ["verify"],
|
|
249
|
+
algorithm: { name: "HMAC", hash: { name: "SHA-256" }, length: 152 },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
// The published vectors must hold in Bun too, or the test itself is wrong.
|
|
253
|
+
for (const v of rfc4231)
|
|
254
|
+
assert.equal(
|
|
255
|
+
hexOf(createHmac("sha256", Buffer.from(v.key, "hex")).update(Buffer.from(v.data, "hex")).digest()),
|
|
256
|
+
v.mac,
|
|
257
|
+
);
|
|
258
|
+
assert.equal(
|
|
259
|
+
toB64Url(
|
|
260
|
+
createHmac("sha256", Buffer.from(rfc7515Key, "base64url"))
|
|
261
|
+
.update(rfc7515.slice(0, rfc7515.lastIndexOf(".")))
|
|
262
|
+
.digest(),
|
|
263
|
+
),
|
|
264
|
+
rfc7515.slice(rfc7515.lastIndexOf(".") + 1),
|
|
265
|
+
);
|
|
266
|
+
assert.equal(
|
|
267
|
+
toB64Url(
|
|
268
|
+
createHmac("sha256", jwtIoKey)
|
|
269
|
+
.update(jwtIo.slice(0, jwtIo.lastIndexOf(".")))
|
|
270
|
+
.digest(),
|
|
271
|
+
),
|
|
272
|
+
jwtIo.slice(jwtIo.lastIndexOf(".") + 1),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-jwt-"));
|
|
276
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
277
|
+
try {
|
|
278
|
+
const porffor = await ensurePorffor();
|
|
279
|
+
const node = Bun.which("node");
|
|
280
|
+
if (!node) throw new Error("native jwt test needs node on PATH");
|
|
281
|
+
|
|
282
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
283
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
284
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
285
|
+
const generated = join(workdir, "jwt.generated.js");
|
|
286
|
+
const binary = join(workdir, "jwt-native");
|
|
287
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
288
|
+
|
|
289
|
+
const compile = Bun.spawn(
|
|
290
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
291
|
+
{
|
|
292
|
+
cwd: workdir,
|
|
293
|
+
env: process.env,
|
|
294
|
+
stdout: "pipe",
|
|
295
|
+
stderr: "pipe",
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
299
|
+
compile.exited,
|
|
300
|
+
new Response(compile.stdout).text(),
|
|
301
|
+
new Response(compile.stderr).text(),
|
|
302
|
+
]);
|
|
303
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
304
|
+
|
|
305
|
+
const port = freePort();
|
|
306
|
+
server = Bun.spawn([binary], {
|
|
307
|
+
cwd: workdir,
|
|
308
|
+
env: { ...process.env, PORT: String(port) },
|
|
309
|
+
stdout: "ignore",
|
|
310
|
+
stderr: "inherit",
|
|
311
|
+
});
|
|
312
|
+
let response: Response | undefined;
|
|
313
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
314
|
+
try {
|
|
315
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(20000) });
|
|
316
|
+
break;
|
|
317
|
+
} catch {
|
|
318
|
+
if (server.exitCode !== null) break;
|
|
319
|
+
await Bun.sleep(100);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
assert(response, "compiled jwt handler did not start");
|
|
323
|
+
assert.equal(response.status, 200);
|
|
324
|
+
const actual: typeof expected = await response.json();
|
|
325
|
+
assert.deepEqual(actual, expected);
|
|
326
|
+
console.log("Native JWT pieces (atob, TextEncoder, HMAC verify) match RFC 4231, RFC 7515, jwt.io and Bun");
|
|
327
|
+
} finally {
|
|
328
|
+
if (server) {
|
|
329
|
+
server.kill(9);
|
|
330
|
+
await server.exited;
|
|
331
|
+
}
|
|
332
|
+
await rm(workdir, { recursive: true, force: true });
|
|
333
|
+
}
|