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 CHANGED
@@ -233,7 +233,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
233
233
 
234
234
  [[package]]
235
235
  name = "castrum"
236
- version = "0.9.6"
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.11.0"
1078
+ version = "3.12.2"
1079
1079
  source = "registry+https://github.com/rust-lang/crates.io-index"
1080
- checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941"
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.6"
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
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: {
@@ -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/`, 119 `castrum_*` symbols — 107 direct + 4
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
@@ -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/`, **119 `castrum_*` symbols** — 107 direct + 4
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
 
@@ -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
@@ -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 (119 castrum_* symbols — 107 direct + 4
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.6",
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",
@@ -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 vs = pos + 1;
87
- let mut ve = vs;
88
- while ve < bytes.len() && bytes[ve] != b'"' {
89
- ve += 1;
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
@@ -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
- let Some(tok) = sealed else {
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
- let Some((exp, id, data)) = opened else {
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 o = slice::from_raw_parts_mut(out, need);
696
- o[0] = 1;
697
- o[1..9].copy_from_slice(&exp.to_le_bytes());
698
- let id_len = id.len() as u32;
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', '')
@@ -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