castrum 0.9.6 → 0.9.7
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/Cargo.lock +4 -3
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.js +28 -7
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CASE_STUDY.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +12 -0
- package/docs/HOW_TO_ADD_AN_OP.md +24 -0
- package/docs/REPO_MAP.md +1 -1
- package/package.json +1 -1
- package/rust/crypto/session.rs +164 -7
- package/rust/ffi/crypto.rs +119 -29
- package/rust/ffi/tests.rs +173 -0
- package/src/native/ffi/build/instances.ts +47 -0
- package/src/native/ffi/build/metrics.ts +0 -7
- package/src/native/ffi/types.ts +17 -0
- package/src/native/ffi.ts +12 -0
package/Cargo.lock
CHANGED
|
@@ -233,7 +233,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|
|
233
233
|
|
|
234
234
|
[[package]]
|
|
235
235
|
name = "castrum"
|
|
236
|
-
version = "0.9.
|
|
236
|
+
version = "0.9.7"
|
|
237
237
|
dependencies = [
|
|
238
238
|
"argon2",
|
|
239
239
|
"aws-lc-rs",
|
|
@@ -1075,13 +1075,14 @@ dependencies = [
|
|
|
1075
1075
|
|
|
1076
1076
|
[[package]]
|
|
1077
1077
|
name = "napi"
|
|
1078
|
-
version = "3.
|
|
1078
|
+
version = "3.12.2"
|
|
1079
1079
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1080
|
-
checksum = "
|
|
1080
|
+
checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09"
|
|
1081
1081
|
dependencies = [
|
|
1082
1082
|
"bitflags",
|
|
1083
1083
|
"ctor",
|
|
1084
1084
|
"futures",
|
|
1085
|
+
"libc",
|
|
1085
1086
|
"napi-build",
|
|
1086
1087
|
"napi-sys",
|
|
1087
1088
|
"nohash-hasher",
|
package/Cargo.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "castrum"
|
|
3
3
|
# Keep in sync with package.json "version" (single source of truth: the npm package).
|
|
4
|
-
version = "0.9.
|
|
4
|
+
version = "0.9.7"
|
|
5
5
|
edition = "2021"
|
|
6
6
|
# Keep in sync with package.json "license" (cargo-deny rejects a crate with no
|
|
7
7
|
# license expression).
|
|
Binary file
|
package/castrum.darwin-x64.node
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/index.js
CHANGED
|
@@ -1085,6 +1085,8 @@ function buildInstances(sym, ctx) {
|
|
|
1085
1085
|
const cookieValidateSym = sym.castrum_cookie_validate;
|
|
1086
1086
|
const sessionSealSym = sym.castrum_session_seal;
|
|
1087
1087
|
const sessionOpenSym = sym.castrum_session_open;
|
|
1088
|
+
const sessionSealBytesSym = sym.castrum_session_seal_bytes;
|
|
1089
|
+
const sessionOpenBytesSym = sym.castrum_session_open_bytes;
|
|
1088
1090
|
const rateLimiterCheckRaw = sym.castrum_rate_limiter_check;
|
|
1089
1091
|
const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key;
|
|
1090
1092
|
const jwtSignBytes = sym.castrum_jwt_sign_bytes;
|
|
@@ -1151,6 +1153,12 @@ function buildInstances(sym, ctx) {
|
|
|
1151
1153
|
const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)));
|
|
1152
1154
|
return w;
|
|
1153
1155
|
},
|
|
1156
|
+
sessionSealBytes(id, dataJson, expSecs, secret) {
|
|
1157
|
+
return sessionSealBytesSym(id, lenOrView(id), dataJson, lenOrView(dataJson), BigInt(expSecs), secret, lenOrView(secret));
|
|
1158
|
+
},
|
|
1159
|
+
sessionOpenBytes(token, secret, output) {
|
|
1160
|
+
return Number(sessionOpenBytesSym(token, lenOrView(token), secret, lenOrView(secret), output, lenOrView(output)));
|
|
1161
|
+
},
|
|
1154
1162
|
rateLimiterCheck(inner, key, nowMs) {
|
|
1155
1163
|
const out = rateScratch;
|
|
1156
1164
|
const w = Number(rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)));
|
|
@@ -1404,6 +1412,18 @@ function selfTestInstances(b) {
|
|
|
1404
1412
|
return false;
|
|
1405
1413
|
if (b.sessionOpen(tok, "wrong", out) !== 0)
|
|
1406
1414
|
return false;
|
|
1415
|
+
const enc = new TextEncoder;
|
|
1416
|
+
const tokB = b.sessionSealBytes(enc.encode("sess-9"), enc.encode('{"n":1}'), 1234567, enc.encode("sekrit"));
|
|
1417
|
+
if (tokB === null || tokB !== tok)
|
|
1418
|
+
return false;
|
|
1419
|
+
const outB = new Uint8Array(256);
|
|
1420
|
+
const wB = b.sessionOpenBytes(enc.encode(tokB), enc.encode("sekrit"), outB);
|
|
1421
|
+
if (wB !== w || outB[0] !== 1)
|
|
1422
|
+
return false;
|
|
1423
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode("wrong"), outB) !== 0)
|
|
1424
|
+
return false;
|
|
1425
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode("sekrit"), outB.subarray(0, 4)) !== wB)
|
|
1426
|
+
return false;
|
|
1407
1427
|
}
|
|
1408
1428
|
let rlKeyThrew = false;
|
|
1409
1429
|
try {
|
|
@@ -1528,7 +1548,6 @@ function selfTestMetrics(b) {
|
|
|
1528
1548
|
console.error("FAIL: recordStr");
|
|
1529
1549
|
return false;
|
|
1530
1550
|
}
|
|
1531
|
-
console.error("CK: counter");
|
|
1532
1551
|
const gauge = b.metricsGauge(handle, "ct_selftest_depth", "q");
|
|
1533
1552
|
if (gauge === METRICS_DECLARE_ERR)
|
|
1534
1553
|
return false;
|
|
@@ -1536,7 +1555,6 @@ function selfTestMetrics(b) {
|
|
|
1536
1555
|
return false;
|
|
1537
1556
|
if (!b.metricsGaugeSetStr(handle, gauge, "io", 6))
|
|
1538
1557
|
return false;
|
|
1539
|
-
console.error("CK: gauge");
|
|
1540
1558
|
const hist = b.metricsHistogram(handle, "ct_selftest_latency", "", "0.1,0.5");
|
|
1541
1559
|
if (hist === METRICS_DECLARE_ERR)
|
|
1542
1560
|
return false;
|
|
@@ -1544,10 +1562,8 @@ function selfTestMetrics(b) {
|
|
|
1544
1562
|
return false;
|
|
1545
1563
|
if (b.metricsRecord(handle, hist, EMPTY, -1))
|
|
1546
1564
|
return false;
|
|
1547
|
-
console.error("CK: hist");
|
|
1548
1565
|
if (b.metricsRecord(handle, counter, encodeUtf8("/a"), 1))
|
|
1549
1566
|
return false;
|
|
1550
|
-
console.error("CK: arity");
|
|
1551
1567
|
const probe = new Uint8Array(16);
|
|
1552
1568
|
const needed = b.metricsRender(handle, probe);
|
|
1553
1569
|
if (needed <= probe.length)
|
|
@@ -1566,7 +1582,6 @@ function selfTestMetrics(b) {
|
|
|
1566
1582
|
`)) {
|
|
1567
1583
|
return false;
|
|
1568
1584
|
}
|
|
1569
|
-
console.error("CK: render");
|
|
1570
1585
|
{
|
|
1571
1586
|
const probe2 = new Uint8Array(16);
|
|
1572
1587
|
const need = b.metricsSnapshot(handle, probe2);
|
|
@@ -1581,7 +1596,6 @@ function selfTestMetrics(b) {
|
|
|
1581
1596
|
if (sv.getUint32(4, true) < 3)
|
|
1582
1597
|
return false;
|
|
1583
1598
|
}
|
|
1584
|
-
console.error("CK: snapshot");
|
|
1585
1599
|
{
|
|
1586
1600
|
const c2 = b.metricsCounter(handle, "ct_selftest_batch", "k");
|
|
1587
1601
|
const buf = new Uint8Array(64);
|
|
@@ -1601,7 +1615,6 @@ function selfTestMetrics(b) {
|
|
|
1601
1615
|
return false;
|
|
1602
1616
|
}
|
|
1603
1617
|
}
|
|
1604
|
-
console.error("CK: batch");
|
|
1605
1618
|
try {
|
|
1606
1619
|
b.metricsCounter(0, "ct_selftest_x", "");
|
|
1607
1620
|
return false;
|
|
@@ -2712,6 +2725,14 @@ function bind(forcedPath, tried = []) {
|
|
|
2712
2725
|
args: abi(["cstring", "cstring", "ptr", "usize"]),
|
|
2713
2726
|
returns: U64_FAST
|
|
2714
2727
|
},
|
|
2728
|
+
castrum_session_seal_bytes: {
|
|
2729
|
+
args: abi(["ptr", "usize", "ptr", "usize", "i64", "ptr", "usize"]),
|
|
2730
|
+
returns: "cstring"
|
|
2731
|
+
},
|
|
2732
|
+
castrum_session_open_bytes: {
|
|
2733
|
+
args: abi(["ptr", "usize", "ptr", "usize", "ptr", "usize"]),
|
|
2734
|
+
returns: U64_FAST
|
|
2735
|
+
},
|
|
2715
2736
|
castrum_task_init: { args: ["u32"], returns: "u32" },
|
|
2716
2737
|
castrum_task_submit: { args: abi(["u32", "ptr", "usize", "usize"]), returns: "u32" },
|
|
2717
2738
|
castrum_task_submit_slice: {
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -31,7 +31,7 @@ The bridge between TypeScript and Rust has **two transports**, both loading the
|
|
|
31
31
|
cdylib (built with napi-rs):
|
|
32
32
|
|
|
33
33
|
- **`bun:ffi` (PRIMARY on Bun)** — `src/native/ffi.ts` `dlopen`s the addon's
|
|
34
|
-
`extern "C"` exports (`rust/ffi/`,
|
|
34
|
+
`extern "C"` exports (`rust/ffi/`, 121 `castrum_*` symbols — 109 direct + 4
|
|
35
35
|
`validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`,
|
|
36
36
|
parity guarded by
|
|
37
37
|
`test/unit/native/ffi-symbol-parity.test.ts`) and Bun JIT-calls them
|
package/docs/CASE_STUDY.md
CHANGED
|
@@ -44,7 +44,7 @@ as a pre-baked pipeline, and still be usable from both runtimes.
|
|
|
44
44
|
### 2.1 One cdylib, two transports
|
|
45
45
|
|
|
46
46
|
The addon is built with napi-rs (so Node can load it), but it *also* exports
|
|
47
|
-
`extern "C"` symbols (`rust/ffi/`, **
|
|
47
|
+
`extern "C"` symbols (`rust/ffi/`, **121 `castrum_*` symbols** — 109 direct + 4
|
|
48
48
|
`validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`) so Bun can
|
|
49
49
|
`dlopen` it directly:
|
|
50
50
|
|
package/docs/FFI_BUN_GUIDE.md
CHANGED
|
@@ -240,6 +240,18 @@ measuring the bare binding suggested the opposite. **Measure through the functio
|
|
|
240
240
|
users call, not the binding** — the guard itself costs ~4 ns, so guarding is
|
|
241
241
|
cheap and routing everything through bytes is not always a win.
|
|
242
242
|
|
|
243
|
+
**A second measured instance: the session seal/open siblings (0.9.7).** They
|
|
244
|
+
land in the same place, and the hinge is the caller's own encode. Through the
|
|
245
|
+
real call path, 200k calls, ns/call: one short-string `TextEncoder.encode`
|
|
246
|
+
costs 45.4; `session_seal` is 613.5 via `cstring` versus 557.8 via
|
|
247
|
+
`castrum_session_seal_bytes` when every argument is ALREADY bytes (−9.1%), but
|
|
248
|
+
620.6 when the caller re-encodes its JS strings per call (**+1.2%, slower**).
|
|
249
|
+
`session_open` mirrors it: 568.5 / 501.1 (−11.9%) / 579.5 (+1.9%). So ~45 ns of
|
|
250
|
+
encode against ~45–67 ns of transcode: the byte form pays only for a caller that
|
|
251
|
+
already holds bytes. Byte-identical tokens between the two forms was asserted at
|
|
252
|
+
runtime (`parity: true`), so the siblings are a correctness escape hatch — the
|
|
253
|
+
NUL case, or a caller that already owns buffers — never a blanket migration.
|
|
254
|
+
|
|
243
255
|
**Rule**: a `cstring` ARG is only correct when truncation cannot change the
|
|
244
256
|
answer — i.e. the string is developer-supplied config or NUL-free by
|
|
245
257
|
construction (a MIME extension, a base64 key, a header name). Anything
|
package/docs/HOW_TO_ADD_AN_OP.md
CHANGED
|
@@ -13,6 +13,30 @@ guards fail.
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
+
### ⚠️ Rebuild BOTH binaries before binding anything
|
|
17
|
+
|
|
18
|
+
`bun run build` writes the **baseline** addon only. The loader CPU-detects and
|
|
19
|
+
PREFERS the x86-64-v3 SIMD variant (`castrum.linux-x64-v3-gnu.node`, built by
|
|
20
|
+
`bun run build:v3`) on any AVX2 host — i.e. essentially every modern x86-64
|
|
21
|
+
machine, including CI runners.
|
|
22
|
+
|
|
23
|
+
A stale v3 binary therefore ships the OLD symbol table, and the failure mode is
|
|
24
|
+
**not** a clean "missing symbol" error: `bun:ffi` binds against it, the first call
|
|
25
|
+
jumps through a missing symbol, and the process **SIGSEGVs** — often at teardown,
|
|
26
|
+
with every test passing. Observed while adding
|
|
27
|
+
`castrum_session_{seal,open}_bytes`: `test/compat` printed `9 pass / 0 fail` and
|
|
28
|
+
then died, `gdb` showing the fault inside `castrum.linux-x64-v3-gnu.node`.
|
|
29
|
+
|
|
30
|
+
So: after adding or changing ANY `castrum_*` export, rebuild with
|
|
31
|
+
`bun run build:all` (baseline + v3). If you hit a segfault with no failing test,
|
|
32
|
+
check this FIRST — it is much cheaper than bisecting the ABI:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
nm -D --defined-only castrum.linux-x64-v3-gnu.node | grep <your_symbol>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
16
40
|
## 0. The six wiring points at a glance
|
|
17
41
|
|
|
18
42
|
| # | File | What you add |
|
package/docs/REPO_MAP.md
CHANGED
|
@@ -178,7 +178,7 @@ rust/ ONE cdylib crate (Cargo [lib] → lib.rs).
|
|
|
178
178
|
CASTRUM_TASK_THREADS, batch dequeue + spin-then-park + optional
|
|
179
179
|
CASTRUM_TASK_PIN_CORES), result ring + batched thread-safe doorbell,
|
|
180
180
|
op dispatch + cancel + catch_unwind containment + `_into` ops.
|
|
181
|
-
ffi/ #[no_mangle] extern "C" exports (
|
|
181
|
+
ffi/ #[no_mangle] extern "C" exports (121 castrum_* symbols — 109 direct + 4
|
|
182
182
|
validator_c_abi! + 4 validator_bytes_c_abi! + 4 compress_to_out!; parity guarded by
|
|
183
183
|
test/unit/native/ffi-symbol-parity.test.ts) for Bun's bun:ffi
|
|
184
184
|
primary transport, incl. castrum_ingress_layout (the layout blob).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
|
|
6
6
|
"type": "module",
|
package/rust/crypto/session.rs
CHANGED
|
@@ -83,13 +83,10 @@ pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)>
|
|
|
83
83
|
if bytes.get(pos) != Some(&b'"') {
|
|
84
84
|
return None;
|
|
85
85
|
}
|
|
86
|
-
let
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
id = Some(bytes[vs..ve].to_vec());
|
|
92
|
-
pos = ve + 1;
|
|
86
|
+
let body = pos + 1;
|
|
87
|
+
let end = scan_json_string_end(bytes, body)?;
|
|
88
|
+
id = Some(unescape_json_id(&bytes[body..end]));
|
|
89
|
+
pos = end + 1;
|
|
93
90
|
}
|
|
94
91
|
b"exp" => {
|
|
95
92
|
let vs = pos;
|
|
@@ -174,6 +171,108 @@ fn skip_json_value(bytes: &[u8], pos: usize) -> Option<usize> {
|
|
|
174
171
|
}
|
|
175
172
|
}
|
|
176
173
|
|
|
174
|
+
/// Index of the `"` that TERMINATES a JSON string whose body starts at `start`
|
|
175
|
+
/// (just past the opening quote).
|
|
176
|
+
///
|
|
177
|
+
/// Escape-aware: the id used to be captured by scanning for the next `"`, which
|
|
178
|
+
/// stops at the quote inside an escaped `\"` — truncating the id AND leaving
|
|
179
|
+
/// `pos` in the middle of the string, so the rest of the envelope was then read
|
|
180
|
+
/// as structure (a quote-carrying id could make `open` fail outright).
|
|
181
|
+
fn scan_json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
|
|
182
|
+
let mut i = start;
|
|
183
|
+
while i < bytes.len() {
|
|
184
|
+
match bytes[i] {
|
|
185
|
+
b'\\' => i += 2,
|
|
186
|
+
b'"' => return Some(i),
|
|
187
|
+
_ => i += 1,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
None
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Decode 4 hex digits at `at` (the `\uXXXX` payload).
|
|
194
|
+
fn hex4(bytes: &[u8], at: usize) -> Option<u32> {
|
|
195
|
+
let mut v = 0u32;
|
|
196
|
+
for k in 0..4 {
|
|
197
|
+
v = (v << 4) | (*bytes.get(at + k)? as char).to_digit(16)?;
|
|
198
|
+
}
|
|
199
|
+
Some(v)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Inverse of `json_ser::write_json_escaped` for the id: decodes `\"`, `\\`,
|
|
203
|
+
/// `\/`, `\b`, `\f`, `\n`, `\r`, `\t` and `\uXXXX`.
|
|
204
|
+
///
|
|
205
|
+
/// `\u00XX` maps back to the single BYTE `XX`. That is deliberate and load
|
|
206
|
+
/// bearing: the escaper emits that form for control characters AND — in its
|
|
207
|
+
/// invalid-UTF-8 mode — for every byte of binary input, so routing it through a
|
|
208
|
+
/// code point and re-encoding as UTF-8 would expand bytes >= 0x80 into two
|
|
209
|
+
/// bytes and destroy the byte-exact round trip the id is supposed to have.
|
|
210
|
+
/// (A valid-UTF-8 id is written RAW by the escaper, so `\u00XX` cannot be its
|
|
211
|
+
/// origin.)
|
|
212
|
+
///
|
|
213
|
+
/// `\uXXXX` above 0xFF can only come from a different producer (e.g. JS
|
|
214
|
+
/// `JSON.stringify` of a non-ASCII id), so it is decoded as a code point with
|
|
215
|
+
/// surrogate-pair combining. Unknown escapes and a trailing backslash are kept
|
|
216
|
+
/// verbatim — never silently dropped.
|
|
217
|
+
fn unescape_json_id(escaped: &[u8]) -> Vec<u8> {
|
|
218
|
+
let mut out = Vec::with_capacity(escaped.len());
|
|
219
|
+
let mut i = 0usize;
|
|
220
|
+
while i < escaped.len() {
|
|
221
|
+
if escaped[i] != b'\\' {
|
|
222
|
+
out.push(escaped[i]);
|
|
223
|
+
i += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
let Some(&e) = escaped.get(i + 1) else {
|
|
227
|
+
out.push(b'\\');
|
|
228
|
+
break;
|
|
229
|
+
};
|
|
230
|
+
i += 2;
|
|
231
|
+
match e {
|
|
232
|
+
b'"' => out.push(b'"'),
|
|
233
|
+
b'\\' => out.push(b'\\'),
|
|
234
|
+
b'/' => out.push(b'/'),
|
|
235
|
+
b'b' => out.push(0x08),
|
|
236
|
+
b'f' => out.push(0x0c),
|
|
237
|
+
b'n' => out.push(b'\n'),
|
|
238
|
+
b'r' => out.push(b'\r'),
|
|
239
|
+
b't' => out.push(b'\t'),
|
|
240
|
+
b'u' => {
|
|
241
|
+
let Some(cp) = hex4(escaped, i) else {
|
|
242
|
+
out.push(b'\\');
|
|
243
|
+
out.push(b'u');
|
|
244
|
+
continue;
|
|
245
|
+
};
|
|
246
|
+
i += 4;
|
|
247
|
+
if cp <= 0xFF {
|
|
248
|
+
out.push(cp as u8);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let mut code = cp;
|
|
252
|
+
if (0xD800..=0xDBFF).contains(&cp)
|
|
253
|
+
&& escaped.get(i) == Some(&b'\\')
|
|
254
|
+
&& escaped.get(i + 1) == Some(&b'u')
|
|
255
|
+
{
|
|
256
|
+
if let Some(lo) = hex4(escaped, i + 2) {
|
|
257
|
+
if (0xDC00..=0xDFFF).contains(&lo) {
|
|
258
|
+
code = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
|
|
259
|
+
i += 6;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let mut buf = [0u8; 4];
|
|
264
|
+
let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
|
|
265
|
+
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
|
266
|
+
}
|
|
267
|
+
other => {
|
|
268
|
+
out.push(b'\\');
|
|
269
|
+
out.push(other);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
out
|
|
274
|
+
}
|
|
275
|
+
|
|
177
276
|
#[cfg(test)]
|
|
178
277
|
mod tests {
|
|
179
278
|
use super::*;
|
|
@@ -209,6 +308,64 @@ mod tests {
|
|
|
209
308
|
assert!(seal_core(b"", b"{}", 0, SECRET).is_none());
|
|
210
309
|
}
|
|
211
310
|
|
|
311
|
+
#[test]
|
|
312
|
+
fn id_round_trips_byte_exactly_through_escapes() {
|
|
313
|
+
// A `"` used to terminate the id scan early, and the id came back
|
|
314
|
+
// escaped. Both are the same root cause: an escape-unaware scan with no
|
|
315
|
+
// decode. Every id here must survive the envelope byte-for-byte.
|
|
316
|
+
for id in [
|
|
317
|
+
&b"plain-id"[..],
|
|
318
|
+
&b"sess\"quote"[..],
|
|
319
|
+
&b"back\\slash"[..],
|
|
320
|
+
&b"new\nline"[..],
|
|
321
|
+
&b"tab\there"[..],
|
|
322
|
+
&b"cr\rhere"[..],
|
|
323
|
+
&b"nul\0byte"[..],
|
|
324
|
+
&b"binary\xff\xfe"[..],
|
|
325
|
+
&b"trailing\\"[..],
|
|
326
|
+
] {
|
|
327
|
+
let token = seal_core(id, br#"{"a":1}"#, 42, SECRET).expect("seal");
|
|
328
|
+
let (exp, got, data) = open_core(&token, SECRET).expect("open");
|
|
329
|
+
assert_eq!(got, id, "id must round-trip byte-exactly");
|
|
330
|
+
assert_eq!(exp, 42);
|
|
331
|
+
assert_eq!(data, br#"{"a":1}"#);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[test]
|
|
336
|
+
fn unescape_json_id_matches_the_escaper() {
|
|
337
|
+
assert_eq!(unescape_json_id(br#"a\"b"#), b"a\"b");
|
|
338
|
+
assert_eq!(unescape_json_id(br#"a\\b"#), b"a\\b");
|
|
339
|
+
assert_eq!(unescape_json_id(br#"a\/b"#), b"a/b");
|
|
340
|
+
assert_eq!(unescape_json_id(br#"a\nb"#), b"a\nb");
|
|
341
|
+
assert_eq!(unescape_json_id(br#"a\rb"#), b"a\rb");
|
|
342
|
+
assert_eq!(unescape_json_id(br#"a\tb"#), b"a\tb");
|
|
343
|
+
assert_eq!(unescape_json_id(br#"a\bb"#), b"a\x08b");
|
|
344
|
+
assert_eq!(unescape_json_id(br#"a\fb"#), b"a\x0cb");
|
|
345
|
+
// `\u00XX` is a BYTE (the escaper's binary mode), not a code point.
|
|
346
|
+
assert_eq!(unescape_json_id(br#"a\u00e9b"#), b"a\xe9b");
|
|
347
|
+
// Above 0xFF is a real code point → UTF-8.
|
|
348
|
+
assert_eq!(unescape_json_id(br#"a\u20acb"#), "a\u{20ac}b".as_bytes());
|
|
349
|
+
// A surrogate pair combines into one code point.
|
|
350
|
+
assert_eq!(unescape_json_id(br#"\ud83d\ude00"#), "\u{1f600}".as_bytes());
|
|
351
|
+
// Unknown escapes and a trailing backslash stay verbatim.
|
|
352
|
+
assert_eq!(unescape_json_id(br#"a\qb"#), br#"a\qb"#);
|
|
353
|
+
assert_eq!(unescape_json_id(b"a\\"), b"a\\");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#[test]
|
|
357
|
+
fn scan_json_string_end_skips_escaped_quotes() {
|
|
358
|
+
// `a\"b"` (a, \, ", b, ") — the escaped quote does NOT terminate; the
|
|
359
|
+
// plain quote at index 4 does.
|
|
360
|
+
assert_eq!(scan_json_string_end(b"a\\\"b\"", 0), Some(4));
|
|
361
|
+
// No escape at all: the first quote terminates.
|
|
362
|
+
assert_eq!(scan_json_string_end(b"ab\"cd", 0), Some(2));
|
|
363
|
+
// Unterminated: the trailing backslash swallows the rest.
|
|
364
|
+
assert_eq!(scan_json_string_end(b"ab\\\"", 0), None);
|
|
365
|
+
// Escaped BACKSLASH then a terminator: `a\\"` = a, \, \, " → Some(3).
|
|
366
|
+
assert_eq!(scan_json_string_end(b"a\\\\\"", 0), Some(3));
|
|
367
|
+
}
|
|
368
|
+
|
|
212
369
|
#[test]
|
|
213
370
|
fn wire_format_matches_sign_cookie() {
|
|
214
371
|
// The sealed token must be verifiable by the EXISTING cookie verify
|
package/rust/ffi/crypto.rs
CHANGED
|
@@ -625,6 +625,58 @@ pub unsafe extern "C" fn castrum_random_token_into(
|
|
|
625
625
|
|
|
626
626
|
// ── Session envelope (fused JSON + HMAC) ────────────────────────────
|
|
627
627
|
|
|
628
|
+
/// Shared tail of both seal entry points: `None` (empty id/secret, or a panic)
|
|
629
|
+
/// → null pointer, otherwise clone the sealed token into the engine's
|
|
630
|
+
/// per-thread cstring buffer. Extracted so the cstring and byte forms cannot
|
|
631
|
+
/// diverge — the byte form exists only to change how the INPUTS cross.
|
|
632
|
+
fn sealed_to_cstr(sealed: Option<Vec<u8>>) -> *const std::os::raw::c_char {
|
|
633
|
+
let Some(tok) = sealed else {
|
|
634
|
+
return std::ptr::null();
|
|
635
|
+
};
|
|
636
|
+
super::util::cstring_return(tok.len(), move |buf| {
|
|
637
|
+
if buf.len() < tok.len() {
|
|
638
|
+
return None;
|
|
639
|
+
}
|
|
640
|
+
buf[..tok.len()].copy_from_slice(&tok);
|
|
641
|
+
Some(tok.len())
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/// Shared tail of both open entry points: pack the extracted envelope into the
|
|
646
|
+
/// caller's buffer as `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`,
|
|
647
|
+
/// or report the EXACT size required when `out_cap` is too small. `None` (bad
|
|
648
|
+
/// signature / malformed envelope) → `0`. Never returns `0` on a valid call.
|
|
649
|
+
///
|
|
650
|
+
/// # Safety
|
|
651
|
+
/// `out` must be valid for writes up to `out_cap`; the caller null-checks it.
|
|
652
|
+
unsafe fn pack_open_result(
|
|
653
|
+
opened: Option<(i64, Vec<u8>, Vec<u8>)>,
|
|
654
|
+
out: *mut u8,
|
|
655
|
+
out_cap: usize,
|
|
656
|
+
) -> usize {
|
|
657
|
+
let Some((exp, id, data)) = opened else {
|
|
658
|
+
return 0;
|
|
659
|
+
};
|
|
660
|
+
// Layout size: 1 + 8 + 4+id + 4+data
|
|
661
|
+
let need = 1 + 8 + 4 + id.len() + 4 + data.len();
|
|
662
|
+
if need > out_cap {
|
|
663
|
+
return need;
|
|
664
|
+
}
|
|
665
|
+
let o = slice::from_raw_parts_mut(out, need);
|
|
666
|
+
o[0] = 1;
|
|
667
|
+
o[1..9].copy_from_slice(&exp.to_le_bytes());
|
|
668
|
+
let id_len = id.len() as u32;
|
|
669
|
+
o[9..13].copy_from_slice(&id_len.to_le_bytes());
|
|
670
|
+
let mut p = 13usize;
|
|
671
|
+
o[p..p + id.len()].copy_from_slice(&id);
|
|
672
|
+
p += id.len();
|
|
673
|
+
let d_len = data.len() as u32;
|
|
674
|
+
o[p..p + 4].copy_from_slice(&d_len.to_le_bytes());
|
|
675
|
+
p += 4;
|
|
676
|
+
o[p..p + data.len()].copy_from_slice(&data);
|
|
677
|
+
need
|
|
678
|
+
}
|
|
679
|
+
|
|
628
680
|
/// Seal a session envelope NATIVELY: build
|
|
629
681
|
/// `{"id":"…","data":<data_json>,"exp":exp}` and HMAC-sign it into the
|
|
630
682
|
/// `payload.<64-hex>` cookie token — ONE crossing replaces the JS
|
|
@@ -650,16 +702,7 @@ pub unsafe extern "C" fn castrum_session_seal(
|
|
|
650
702
|
|| crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
|
|
651
703
|
None,
|
|
652
704
|
);
|
|
653
|
-
|
|
654
|
-
return std::ptr::null();
|
|
655
|
-
};
|
|
656
|
-
super::util::cstring_return(tok.len(), move |buf| {
|
|
657
|
-
if buf.len() < tok.len() {
|
|
658
|
-
return None;
|
|
659
|
-
}
|
|
660
|
-
buf[..tok.len()].copy_from_slice(&tok);
|
|
661
|
-
Some(tok.len())
|
|
662
|
-
})
|
|
705
|
+
sealed_to_cstr(sealed)
|
|
663
706
|
}
|
|
664
707
|
|
|
665
708
|
/// Open a sealed session token: verify the HMAC and extract the envelope in
|
|
@@ -684,25 +727,72 @@ pub unsafe extern "C" fn castrum_session_open(
|
|
|
684
727
|
let tok = std::ffi::CStr::from_ptr(token).to_bytes();
|
|
685
728
|
let sec = std::ffi::CStr::from_ptr(secret).to_bytes();
|
|
686
729
|
let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
|
|
687
|
-
|
|
730
|
+
pack_open_result(opened, out, out_cap)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/// Byte-arg sibling of `castrum_session_seal`: same core, same wire format, but
|
|
734
|
+
/// every input crosses as `(ptr, len)` instead of a `cstring` ARG.
|
|
735
|
+
///
|
|
736
|
+
/// Per `docs/FFI_BUN_GUIDE.md` §6.1 there are two reasons, and only the second
|
|
737
|
+
/// cannot be worked around:
|
|
738
|
+
/// 1. a `cstring` ARG costs the engine a transcode into a call-scoped
|
|
739
|
+
/// NUL-terminated buffer (~80ns per arg through the public surface vs ~37ns
|
|
740
|
+
/// for the byte path);
|
|
741
|
+
/// 2. an embedded `U+0000` SILENTLY TRUNCATES the value before Rust sees it.
|
|
742
|
+
/// `id` and `data_json` are user-derived (the id is escaped by
|
|
743
|
+
/// `seal_core`, which only makes sense if it may contain arbitrary bytes),
|
|
744
|
+
/// so a session id carrying a NUL would be sealed under its truncated form.
|
|
745
|
+
/// This form carries the exact length and cannot be truncated.
|
|
746
|
+
///
|
|
747
|
+
/// # Safety
|
|
748
|
+
/// `id`/`data_json`/`secret` must be valid for reads of their stated lengths;
|
|
749
|
+
/// null pointers are rejected (→ null).
|
|
750
|
+
#[no_mangle]
|
|
751
|
+
pub unsafe extern "C" fn castrum_session_seal_bytes(
|
|
752
|
+
id: *const u8,
|
|
753
|
+
id_len: usize,
|
|
754
|
+
data_json: *const u8,
|
|
755
|
+
data_len: usize,
|
|
756
|
+
exp_secs: i64,
|
|
757
|
+
secret: *const u8,
|
|
758
|
+
secret_len: usize,
|
|
759
|
+
) -> *const std::os::raw::c_char {
|
|
760
|
+
if id.is_null() || data_json.is_null() || secret.is_null() {
|
|
761
|
+
return std::ptr::null();
|
|
762
|
+
}
|
|
763
|
+
let id_b = slice::from_raw_parts(id, id_len);
|
|
764
|
+
let data_b = slice::from_raw_parts(data_json, data_len);
|
|
765
|
+
let sec_b = slice::from_raw_parts(secret, secret_len);
|
|
766
|
+
let sealed = super::util::panic_guard(
|
|
767
|
+
|| crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
|
|
768
|
+
None,
|
|
769
|
+
);
|
|
770
|
+
sealed_to_cstr(sealed)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/// Byte-arg sibling of `castrum_session_open`: `token`/`secret` cross as
|
|
774
|
+
/// `(ptr, len)`; the output layout and the needed-size convention are identical
|
|
775
|
+
/// to `castrum_session_open` (`0` = bad signature / malformed). Saves the
|
|
776
|
+
/// per-arg transcode and, unlike the `cstring` form, cannot be truncated by an
|
|
777
|
+
/// embedded `U+0000` in the token.
|
|
778
|
+
///
|
|
779
|
+
/// # Safety
|
|
780
|
+
/// `token`/`secret` must be valid for reads of their stated lengths; `out` must
|
|
781
|
+
/// be valid for writes up to `out_cap`. Null pointers → 0.
|
|
782
|
+
#[no_mangle]
|
|
783
|
+
pub unsafe extern "C" fn castrum_session_open_bytes(
|
|
784
|
+
token: *const u8,
|
|
785
|
+
token_len: usize,
|
|
786
|
+
secret: *const u8,
|
|
787
|
+
secret_len: usize,
|
|
788
|
+
out: *mut u8,
|
|
789
|
+
out_cap: usize,
|
|
790
|
+
) -> usize {
|
|
791
|
+
if token.is_null() || secret.is_null() || out.is_null() {
|
|
688
792
|
return 0;
|
|
689
|
-
};
|
|
690
|
-
// Layout size: 1 + 8 + 4+id + 4+data
|
|
691
|
-
let need = 1 + 8 + 4 + id.len() + 4 + data.len();
|
|
692
|
-
if need > out_cap {
|
|
693
|
-
return need;
|
|
694
793
|
}
|
|
695
|
-
let
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
o[9..13].copy_from_slice(&id_len.to_le_bytes());
|
|
700
|
-
let mut p = 13usize;
|
|
701
|
-
o[p..p + id.len()].copy_from_slice(&id);
|
|
702
|
-
p += id.len();
|
|
703
|
-
let d_len = data.len() as u32;
|
|
704
|
-
o[p..p + 4].copy_from_slice(&d_len.to_le_bytes());
|
|
705
|
-
p += 4;
|
|
706
|
-
o[p..p + data.len()].copy_from_slice(&data);
|
|
707
|
-
need
|
|
794
|
+
let tok = slice::from_raw_parts(token, token_len);
|
|
795
|
+
let sec = slice::from_raw_parts(secret, secret_len);
|
|
796
|
+
let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
|
|
797
|
+
pack_open_result(opened, out, out_cap)
|
|
708
798
|
}
|
package/rust/ffi/tests.rs
CHANGED
|
@@ -2308,6 +2308,179 @@ fn wire_validate_and_session_c_abi() {
|
|
|
2308
2308
|
unsafe { castrum_session_open(token, c"wrong".as_ptr(), buf.as_mut_ptr(), buf.len()) },
|
|
2309
2309
|
0
|
|
2310
2310
|
);
|
|
2311
|
+
|
|
2312
|
+
// ── Byte-arg siblings ──────────────────────────────────────────
|
|
2313
|
+
// Copy the first token OUT of the shared per-thread CSTR_BUF before the
|
|
2314
|
+
// next `cstring` return reuses it (that buffer is one-per-thread by design).
|
|
2315
|
+
let token_str = unsafe { std::ffi::CStr::from_ptr(token) }
|
|
2316
|
+
.to_string_lossy()
|
|
2317
|
+
.into_owned();
|
|
2318
|
+
let id = b"sess-9";
|
|
2319
|
+
let data = b"{\"n\":1}";
|
|
2320
|
+
let secret = b"sekrit";
|
|
2321
|
+
let token_b = unsafe {
|
|
2322
|
+
castrum_session_seal_bytes(
|
|
2323
|
+
id.as_ptr(),
|
|
2324
|
+
id.len(),
|
|
2325
|
+
data.as_ptr(),
|
|
2326
|
+
data.len(),
|
|
2327
|
+
1_234_567,
|
|
2328
|
+
secret.as_ptr(),
|
|
2329
|
+
secret.len(),
|
|
2330
|
+
)
|
|
2331
|
+
};
|
|
2332
|
+
assert!(!token_b.is_null());
|
|
2333
|
+
// One shared core ⇒ byte-identical token. This is the parity guarantee that
|
|
2334
|
+
// makes the byte form a drop-in for the cstring form.
|
|
2335
|
+
let token_b_str = unsafe { std::ffi::CStr::from_ptr(token_b) }
|
|
2336
|
+
.to_string_lossy()
|
|
2337
|
+
.into_owned();
|
|
2338
|
+
assert_eq!(token_b_str, token_str);
|
|
2339
|
+
|
|
2340
|
+
let tok_bytes = token_b_str.as_bytes();
|
|
2341
|
+
let mut buf2 = [0u8; 256];
|
|
2342
|
+
let w2 = unsafe {
|
|
2343
|
+
castrum_session_open_bytes(
|
|
2344
|
+
tok_bytes.as_ptr(),
|
|
2345
|
+
tok_bytes.len(),
|
|
2346
|
+
secret.as_ptr(),
|
|
2347
|
+
secret.len(),
|
|
2348
|
+
buf2.as_mut_ptr(),
|
|
2349
|
+
buf2.len(),
|
|
2350
|
+
)
|
|
2351
|
+
};
|
|
2352
|
+
assert_eq!(w2, w);
|
|
2353
|
+
assert_eq!(buf2[..w2], buf[..w]);
|
|
2354
|
+
// bad signature → 0
|
|
2355
|
+
assert_eq!(
|
|
2356
|
+
unsafe {
|
|
2357
|
+
castrum_session_open_bytes(
|
|
2358
|
+
tok_bytes.as_ptr(),
|
|
2359
|
+
tok_bytes.len(),
|
|
2360
|
+
b"wrong".as_ptr(),
|
|
2361
|
+
5,
|
|
2362
|
+
buf2.as_mut_ptr(),
|
|
2363
|
+
buf2.len(),
|
|
2364
|
+
)
|
|
2365
|
+
},
|
|
2366
|
+
0
|
|
2367
|
+
);
|
|
2368
|
+
// Too-small buffer → the EXACT required size, not 0 (needed-size rule).
|
|
2369
|
+
assert_eq!(
|
|
2370
|
+
unsafe {
|
|
2371
|
+
castrum_session_open_bytes(
|
|
2372
|
+
tok_bytes.as_ptr(),
|
|
2373
|
+
tok_bytes.len(),
|
|
2374
|
+
secret.as_ptr(),
|
|
2375
|
+
secret.len(),
|
|
2376
|
+
buf2.as_mut_ptr(),
|
|
2377
|
+
4,
|
|
2378
|
+
)
|
|
2379
|
+
},
|
|
2380
|
+
w2
|
|
2381
|
+
);
|
|
2382
|
+
|
|
2383
|
+
// The reason this sibling exists: the cstring form TRUNCATES at an embedded
|
|
2384
|
+
// NUL (the engine's transcode is NUL-terminated), so an id carrying one is
|
|
2385
|
+
// sealed under its truncated form. The byte form carries the exact length.
|
|
2386
|
+
//
|
|
2387
|
+
// Each token is copied to a String IMMEDIATELY after its own call: both
|
|
2388
|
+
// `cstring` returns write into ONE per-thread CSTR_BUF, so the earlier
|
|
2389
|
+
// pointer is dead the moment the next call reuses it — the aliasing hazard
|
|
2390
|
+
// `docs/FFI_BUN_GUIDE.md` documents. The assertions below also read the
|
|
2391
|
+
// recovered `id` BYTES rather than the token's text, so they hold
|
|
2392
|
+
// regardless of how the payload is wire-encoded.
|
|
2393
|
+
let nul_id = b"sess-9\0extra";
|
|
2394
|
+
let tp = unsafe {
|
|
2395
|
+
castrum_session_seal(
|
|
2396
|
+
nul_id.as_ptr() as *const std::os::raw::c_char,
|
|
2397
|
+
c"{\"n\":1}".as_ptr(),
|
|
2398
|
+
1_234_567,
|
|
2399
|
+
c"sekrit".as_ptr(),
|
|
2400
|
+
)
|
|
2401
|
+
};
|
|
2402
|
+
assert!(!tp.is_null());
|
|
2403
|
+
let truncated_str = unsafe { std::ffi::CStr::from_ptr(tp) }
|
|
2404
|
+
.to_string_lossy()
|
|
2405
|
+
.into_owned();
|
|
2406
|
+
|
|
2407
|
+
let ep = unsafe {
|
|
2408
|
+
castrum_session_seal_bytes(
|
|
2409
|
+
nul_id.as_ptr(),
|
|
2410
|
+
nul_id.len(),
|
|
2411
|
+
data.as_ptr(),
|
|
2412
|
+
data.len(),
|
|
2413
|
+
1_234_567,
|
|
2414
|
+
secret.as_ptr(),
|
|
2415
|
+
secret.len(),
|
|
2416
|
+
)
|
|
2417
|
+
};
|
|
2418
|
+
assert!(!ep.is_null());
|
|
2419
|
+
let exact_str = unsafe { std::ffi::CStr::from_ptr(ep) }
|
|
2420
|
+
.to_string_lossy()
|
|
2421
|
+
.into_owned();
|
|
2422
|
+
|
|
2423
|
+
assert_ne!(
|
|
2424
|
+
exact_str, truncated_str,
|
|
2425
|
+
"byte form must not truncate at NUL"
|
|
2426
|
+
);
|
|
2427
|
+
|
|
2428
|
+
// Open each token and read back the `id` from the packed layout
|
|
2429
|
+
// (`[u8 ok][i64 exp][u32 idLen][id]…`) — the ground truth this change is
|
|
2430
|
+
// about. NOTE: `seal_core` JSON-escapes the id but `open_core` returns the
|
|
2431
|
+
// raw captured span, so an id needing escapes comes back escaped. That
|
|
2432
|
+
// asymmetry is pre-existing; these assertions are written not to depend on
|
|
2433
|
+
// it, only on the truncation itself.
|
|
2434
|
+
let id_of = |tok: &str, out: &mut [u8; 256]| -> Vec<u8> {
|
|
2435
|
+
let w = unsafe {
|
|
2436
|
+
castrum_session_open_bytes(
|
|
2437
|
+
tok.as_ptr(),
|
|
2438
|
+
tok.len(),
|
|
2439
|
+
secret.as_ptr(),
|
|
2440
|
+
secret.len(),
|
|
2441
|
+
out.as_mut_ptr(),
|
|
2442
|
+
out.len(),
|
|
2443
|
+
)
|
|
2444
|
+
};
|
|
2445
|
+
assert!(w > 13, "open of {tok:?} failed");
|
|
2446
|
+
let id_len = u32::from_le_bytes([out[9], out[10], out[11], out[12]]) as usize;
|
|
2447
|
+
out[13..13 + id_len].to_vec()
|
|
2448
|
+
};
|
|
2449
|
+
let mut scratch = [0u8; 256];
|
|
2450
|
+
let truncated_id = id_of(&truncated_str, &mut scratch);
|
|
2451
|
+
let exact_id = id_of(&exact_str, &mut scratch);
|
|
2452
|
+
assert_eq!(truncated_id, b"sess-9");
|
|
2453
|
+
// The byte form carries the exact length, so the id comes back WHOLE — and
|
|
2454
|
+
// byte-exact, because `open_core` now decodes the id rather than returning
|
|
2455
|
+
// the escaped span.
|
|
2456
|
+
assert_eq!(exact_id, &nul_id[..]);
|
|
2457
|
+
|
|
2458
|
+
// Null pointers are rejected, never dereferenced.
|
|
2459
|
+
assert!(unsafe {
|
|
2460
|
+
castrum_session_seal_bytes(
|
|
2461
|
+
std::ptr::null(),
|
|
2462
|
+
0,
|
|
2463
|
+
std::ptr::null(),
|
|
2464
|
+
0,
|
|
2465
|
+
0,
|
|
2466
|
+
std::ptr::null(),
|
|
2467
|
+
0,
|
|
2468
|
+
)
|
|
2469
|
+
}
|
|
2470
|
+
.is_null());
|
|
2471
|
+
assert_eq!(
|
|
2472
|
+
unsafe {
|
|
2473
|
+
castrum_session_open_bytes(
|
|
2474
|
+
std::ptr::null(),
|
|
2475
|
+
0,
|
|
2476
|
+
secret.as_ptr(),
|
|
2477
|
+
secret.len(),
|
|
2478
|
+
buf2.as_mut_ptr(),
|
|
2479
|
+
buf2.len(),
|
|
2480
|
+
)
|
|
2481
|
+
},
|
|
2482
|
+
0
|
|
2483
|
+
);
|
|
2311
2484
|
}
|
|
2312
2485
|
|
|
2313
2486
|
#[test]
|
|
@@ -41,6 +41,8 @@ export function buildInstances(
|
|
|
41
41
|
const cookieValidateSym = sym.castrum_cookie_validate as (...a: unknown[]) => number | bigint
|
|
42
42
|
const sessionSealSym = sym.castrum_session_seal as (...a: unknown[]) => string | null
|
|
43
43
|
const sessionOpenSym = sym.castrum_session_open as Raw4
|
|
44
|
+
const sessionSealBytesSym = sym.castrum_session_seal_bytes as RawCStr
|
|
45
|
+
const sessionOpenBytesSym = sym.castrum_session_open_bytes as Raw6
|
|
44
46
|
const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
|
|
45
47
|
const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
|
|
46
48
|
const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
|
|
@@ -164,6 +166,33 @@ export function buildInstances(
|
|
|
164
166
|
const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)))
|
|
165
167
|
return w
|
|
166
168
|
},
|
|
169
|
+
sessionSealBytes(id, dataJson, expSecs, secret) {
|
|
170
|
+
// Byte-arg form: exact lengths, no engine transcode, no NUL truncation.
|
|
171
|
+
// The parity with `sessionSeal` is asserted in the bind-time self-test
|
|
172
|
+
// below (same core ⇒ byte-identical token for identical inputs).
|
|
173
|
+
return sessionSealBytesSym(
|
|
174
|
+
id,
|
|
175
|
+
lenOrView(id),
|
|
176
|
+
dataJson,
|
|
177
|
+
lenOrView(dataJson),
|
|
178
|
+
BigInt(expSecs),
|
|
179
|
+
secret,
|
|
180
|
+
lenOrView(secret),
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
sessionOpenBytes(token, secret, output) {
|
|
184
|
+
// Needed-size convention; 0 = bad signature / malformed.
|
|
185
|
+
return Number(
|
|
186
|
+
sessionOpenBytesSym(
|
|
187
|
+
token,
|
|
188
|
+
lenOrView(token),
|
|
189
|
+
secret,
|
|
190
|
+
lenOrView(secret),
|
|
191
|
+
output,
|
|
192
|
+
lenOrView(output),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
},
|
|
167
196
|
rateLimiterCheck(inner, key, nowMs) {
|
|
168
197
|
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
169
198
|
// Reused scratch + cached DataView (no per-call allocs). `key` is a
|
|
@@ -623,6 +652,24 @@ export function selfTestInstances(b: BunFFI): boolean {
|
|
|
623
652
|
if (w <= 13 || out[0] !== 1) return false
|
|
624
653
|
// bad signature → 0
|
|
625
654
|
if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
|
|
655
|
+
|
|
656
|
+
// Byte-arg siblings. Both forms share ONE core, so the sealed token must be
|
|
657
|
+
// byte-identical — the strongest available parity check between the two.
|
|
658
|
+
const enc = new TextEncoder()
|
|
659
|
+
const tokB = b.sessionSealBytes(
|
|
660
|
+
enc.encode('sess-9'),
|
|
661
|
+
enc.encode('{"n":1}'),
|
|
662
|
+
1234567,
|
|
663
|
+
enc.encode('sekrit'),
|
|
664
|
+
)
|
|
665
|
+
if (tokB === null || tokB !== tok) return false
|
|
666
|
+
const outB = new Uint8Array(256)
|
|
667
|
+
const wB = b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB)
|
|
668
|
+
if (wB !== w || outB[0] !== 1) return false
|
|
669
|
+
// Bad signature → 0; a too-small buffer reports the EXACT size, not 0.
|
|
670
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('wrong'), outB) !== 0) return false
|
|
671
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB.subarray(0, 4)) !== wB)
|
|
672
|
+
return false
|
|
626
673
|
}
|
|
627
674
|
|
|
628
675
|
// rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
|
|
@@ -159,23 +159,19 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
159
159
|
return false
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
console.error('CK: counter')
|
|
163
162
|
const gauge = b.metricsGauge(handle, 'ct_selftest_depth', 'q')
|
|
164
163
|
if (gauge === METRICS_DECLARE_ERR) return false
|
|
165
164
|
if (!b.metricsGaugeSet(handle, gauge, encodeUtf8('jobs'), 4)) return false
|
|
166
165
|
if (!b.metricsGaugeSetStr(handle, gauge, 'io', 6)) return false
|
|
167
166
|
|
|
168
|
-
console.error('CK: gauge')
|
|
169
167
|
const hist = b.metricsHistogram(handle, 'ct_selftest_latency', '', '0.1,0.5')
|
|
170
168
|
if (hist === METRICS_DECLARE_ERR) return false
|
|
171
169
|
if (!b.metricsRecord(handle, hist, EMPTY, 0.25)) return false
|
|
172
170
|
if (b.metricsRecord(handle, hist, EMPTY, -1)) return false // negatives rejected
|
|
173
171
|
|
|
174
|
-
console.error('CK: hist')
|
|
175
172
|
// Arity mismatch must fail safely.
|
|
176
173
|
if (b.metricsRecord(handle, counter, encodeUtf8('/a'), 1)) return false
|
|
177
174
|
|
|
178
|
-
console.error('CK: arity')
|
|
179
175
|
// Render: probe with a small buffer → exact required size; then render.
|
|
180
176
|
const probe = new Uint8Array(16)
|
|
181
177
|
const needed = b.metricsRender(handle, probe)
|
|
@@ -195,7 +191,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
195
191
|
return false
|
|
196
192
|
}
|
|
197
193
|
|
|
198
|
-
console.error('CK: render')
|
|
199
194
|
// Snapshot: packed dump starts with version=1 + familyCount>=3.
|
|
200
195
|
{
|
|
201
196
|
const probe2 = new Uint8Array(16)
|
|
@@ -208,7 +203,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
208
203
|
if (sv.getUint32(4, true) < 3) return false
|
|
209
204
|
}
|
|
210
205
|
|
|
211
|
-
console.error('CK: snapshot')
|
|
212
206
|
// Batch: two entries in ONE crossing.
|
|
213
207
|
{
|
|
214
208
|
const c2 = b.metricsCounter(handle, 'ct_selftest_batch', 'k')
|
|
@@ -229,7 +223,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
229
223
|
}
|
|
230
224
|
}
|
|
231
225
|
|
|
232
|
-
console.error('CK: batch')
|
|
233
226
|
// A null handle must throw on declare (never dereference freed state).
|
|
234
227
|
try {
|
|
235
228
|
b.metricsCounter(0, 'ct_selftest_x', '')
|
package/src/native/ffi/types.ts
CHANGED
|
@@ -471,6 +471,23 @@ export interface BunFFI {
|
|
|
471
471
|
* exact required size).
|
|
472
472
|
*/
|
|
473
473
|
sessionOpen(token: string, secret: string, output: Uint8Array): number
|
|
474
|
+
/**
|
|
475
|
+
* Byte-arg `{@link sessionSeal}`: same envelope + wire format, inputs as
|
|
476
|
+
* bytes. Preferred for user-derived `id`/`dataJson`, because a `cstring` ARG
|
|
477
|
+
* is engine-transcoded (a per-arg cost) and SILENTLY TRUNCATES at an
|
|
478
|
+
* embedded `U+0000` before Rust sees it. `null` = failure.
|
|
479
|
+
*/
|
|
480
|
+
sessionSealBytes(
|
|
481
|
+
id: Uint8Array,
|
|
482
|
+
dataJson: Uint8Array,
|
|
483
|
+
expSecs: number,
|
|
484
|
+
secret: Uint8Array,
|
|
485
|
+
): string | null
|
|
486
|
+
/**
|
|
487
|
+
* Byte-arg `{@link sessionOpen}`: same output layout and needed-size
|
|
488
|
+
* convention, `token`/`secret` as bytes (exact length, no transcode).
|
|
489
|
+
*/
|
|
490
|
+
sessionOpenBytes(token: Uint8Array, secret: Uint8Array, output: Uint8Array): number
|
|
474
491
|
|
|
475
492
|
// ── Batch fixed-width hex validation / RegExp escaping ─────────────
|
|
476
493
|
/**
|
package/src/native/ffi.ts
CHANGED
|
@@ -576,6 +576,18 @@ function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
|
|
|
576
576
|
args: abi(['cstring', 'cstring', 'ptr', 'usize']),
|
|
577
577
|
returns: U64_FAST,
|
|
578
578
|
},
|
|
579
|
+
// Byte-arg siblings of the pair above: identical core and wire format,
|
|
580
|
+
// but every INPUT crosses as `(ptr,len)`. A `cstring` ARG costs the
|
|
581
|
+
// engine a transcode per arg AND silently truncates at an embedded
|
|
582
|
+
// U+0000 — reachable here because `id`/`data_json` are user-derived.
|
|
583
|
+
castrum_session_seal_bytes: {
|
|
584
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
|
|
585
|
+
returns: 'cstring',
|
|
586
|
+
},
|
|
587
|
+
castrum_session_open_bytes: {
|
|
588
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
589
|
+
returns: U64_FAST,
|
|
590
|
+
},
|
|
579
591
|
// Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
|
|
580
592
|
// plus a JS-assigned id; `drain` writes the packed completion batch into
|
|
581
593
|
// a caller buffer (needed-size convention). `set_doorbell` takes the raw
|