castrum 0.9.6 → 0.9.8

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.
Files changed (88) hide show
  1. package/Cargo.lock +4 -3
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +501 -5
  12. package/dist/index.js +1658 -1314
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/BENCHMARKS.md +13 -0
  15. package/docs/CASE_STUDY.md +2 -2
  16. package/docs/ENVIRONMENT.md +1 -1
  17. package/docs/FFI_BUN_GUIDE.md +13 -1
  18. package/docs/HOW_TO_ADD_AN_OP.md +24 -0
  19. package/docs/INGRESS.md +4 -4
  20. package/docs/REPO_MAP.md +8 -6
  21. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  22. package/index.ts +1 -0
  23. package/package.json +3 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +170 -7
  29. package/rust/ffi/crypto.rs +182 -61
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +51 -32
  35. package/rust/ffi/tests.rs +197 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/native_route.rs +21 -3
  40. package/rust/task/completion.rs +7 -0
  41. package/rust/task/mod.rs +1 -1
  42. package/rust/task/tests.rs +30 -0
  43. package/src/ingress/decode/baked-result.ts +13 -5
  44. package/src/ingress/decode/fast-result.ts +1 -1
  45. package/src/ingress/decode/result-base.ts +16 -2
  46. package/src/ingress/handlers.ts +103 -31
  47. package/src/ingress/headers/memoized-headers.ts +40 -0
  48. package/src/ingress/native-route.ts +25 -6
  49. package/src/ingress/options.ts +26 -2
  50. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  51. package/src/ingress/response/baked-response.ts +118 -28
  52. package/src/ingress/router.ts +14 -2
  53. package/src/ingress/routes/common.ts +26 -10
  54. package/src/ingress/routes/echo.ts +50 -7
  55. package/src/ingress/routes/json-write.ts +21 -8
  56. package/src/ingress/routes/responder.ts +4 -4
  57. package/src/ingress/server-node.ts +33 -14
  58. package/src/ingress/server.ts +3 -4
  59. package/src/ingress/shared.ts +1 -1
  60. package/src/ingress/types.ts +15 -0
  61. package/src/loader/ops.ts +1 -1
  62. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  63. package/src/native/ffi/build/codecs.ts +0 -296
  64. package/src/native/ffi/build/instances-selftest.ts +264 -0
  65. package/src/native/ffi/build/instances.ts +39 -241
  66. package/src/native/ffi/build/metrics.ts +0 -7
  67. package/src/native/ffi/build/parse-selftest.ts +238 -0
  68. package/src/native/ffi/build/parse.ts +11 -234
  69. package/src/native/ffi/build.ts +5 -1
  70. package/src/native/ffi/selftest.ts +3 -3
  71. package/src/native/ffi/symbols.ts +460 -0
  72. package/src/native/ffi/types.ts +17 -0
  73. package/src/native/ffi.ts +2 -422
  74. package/src/native/ingress-binding.ts +39 -0
  75. package/src/runtime/codec.ts +24 -0
  76. package/src/runtime/types.ts +11 -0
  77. package/src/rust-ffi/batch/build.ts +18 -8
  78. package/src/rust-ffi/batch/types.ts +15 -0
  79. package/src/rust-ffi/options.ts +19 -0
  80. package/src/rust-ffi/scalar/crypto.ts +8 -0
  81. package/src/rust-ffi/scalar/factories.ts +47 -20
  82. package/src/rust-ffi/scalar/http.ts +60 -10
  83. package/src/rust-ffi/scalar/interface.ts +4 -1
  84. package/src/rust-ffi/scalar/payload.ts +27 -1
  85. package/src/shared/buffer-pool.ts +44 -9
  86. package/src/shared/codec.ts +26 -0
  87. package/src/shared/packed/parsers.ts +64 -7
  88. package/src/task/runtime.ts +29 -28
@@ -24,10 +24,18 @@ pub unsafe extern "C" fn castrum_ws_accept_key(
24
24
  if key.is_null() {
25
25
  return std::ptr::null();
26
26
  }
27
- cstring_return(28, |out| {
28
- crate::payload::websocket::ws_accept_key_into(std::ffi::CStr::from_ptr(key).to_bytes(), out)
29
- .ok()
30
- })
27
+ panic_guard(
28
+ || {
29
+ cstring_return(28, |out| {
30
+ crate::payload::websocket::ws_accept_key_into(
31
+ std::ffi::CStr::from_ptr(key).to_bytes(),
32
+ out,
33
+ )
34
+ .ok()
35
+ })
36
+ },
37
+ std::ptr::null(),
38
+ )
31
39
  }
32
40
 
33
41
  /// RFC 6455 Sec-WebSocket-Accept written directly into a caller buffer — the
@@ -76,14 +84,19 @@ pub unsafe extern "C" fn castrum_ws_frame_encode(
76
84
  if payload.is_null() || out.is_null() {
77
85
  return 0;
78
86
  }
79
- crate::payload::ws_frames::encode_frame_into(
80
- opcode,
81
- slice::from_raw_parts(payload, plen),
82
- mask != 0,
83
- fin != 0,
84
- slice::from_raw_parts_mut(out, out_cap),
87
+ panic_guard(
88
+ || {
89
+ crate::payload::ws_frames::encode_frame_into(
90
+ opcode,
91
+ slice::from_raw_parts(payload, plen),
92
+ mask != 0,
93
+ fin != 0,
94
+ slice::from_raw_parts_mut(out, out_cap),
95
+ )
96
+ .unwrap_or(0)
97
+ },
98
+ 0,
85
99
  )
86
- .unwrap_or(0)
87
100
  }
88
101
 
89
102
  /// Decode a WebSocket frame into a packed `[u8 flags][u8 opcode][u32 payload_len]
@@ -102,22 +115,29 @@ pub unsafe extern "C" fn castrum_ws_frame_decode_packed(
102
115
  if data.is_null() || out.is_null() {
103
116
  return 0;
104
117
  }
105
- let Some(frame) = crate::payload::ws_frames::decode_frame(slice::from_raw_parts(data, len))
106
- else {
107
- return 0;
108
- };
109
- let Some(need) = 6usize.checked_add(frame.payload.len()) else {
110
- return 0;
111
- };
112
- if need > out_cap {
113
- return 0;
114
- }
115
- let o = slice::from_raw_parts_mut(out, need);
116
- o[0] = u8::from(frame.fin);
117
- o[1] = frame.opcode;
118
- o[2..6].copy_from_slice(&(frame.payload.len() as u32).to_le_bytes());
119
- o[6..].copy_from_slice(&frame.payload);
120
- need
118
+ // Attacker-controlled frame bytes: a panic must become 0.
119
+ panic_guard(
120
+ || {
121
+ let Some(frame) =
122
+ crate::payload::ws_frames::decode_frame(slice::from_raw_parts(data, len))
123
+ else {
124
+ return 0;
125
+ };
126
+ let Some(need) = 6usize.checked_add(frame.payload.len()) else {
127
+ return 0;
128
+ };
129
+ if need > out_cap {
130
+ return 0;
131
+ }
132
+ let o = slice::from_raw_parts_mut(out, need);
133
+ o[0] = u8::from(frame.fin);
134
+ o[1] = frame.opcode;
135
+ o[2..6].copy_from_slice(&(frame.payload.len() as u32).to_le_bytes());
136
+ o[6..].copy_from_slice(&frame.payload);
137
+ need
138
+ },
139
+ 0,
140
+ )
121
141
  }
122
142
 
123
143
  // ── Compression (gzip / brotli) ──────────────────────────────────
package/rust/ffi/task.rs CHANGED
@@ -32,12 +32,17 @@ pub unsafe extern "C" fn castrum_task_submit(
32
32
  if args.is_null() && args_len != 0 {
33
33
  return 0;
34
34
  }
35
- let owned: Vec<u8> = if args_len == 0 {
36
- Vec::new()
37
- } else {
38
- slice::from_raw_parts(args, args_len).to_vec()
39
- };
40
- u32::from(task::submit_op(op, owned, task_id as u64))
35
+ panic_guard(
36
+ || {
37
+ let owned: Vec<u8> = if args_len == 0 {
38
+ Vec::new()
39
+ } else {
40
+ slice::from_raw_parts(args, args_len).to_vec()
41
+ };
42
+ u32::from(task::submit_op(op, owned, task_id as u64))
43
+ },
44
+ 0,
45
+ )
41
46
  }
42
47
 
43
48
  /// Drain finished tasks into `out` (packed `[u32 count][entry…]`).
@@ -85,18 +90,23 @@ pub unsafe extern "C" fn castrum_task_submit_out(
85
90
  if (args.is_null() && args_len != 0) || out.is_null() || out_cap == 0 {
86
91
  return 0;
87
92
  }
88
- let owned: Vec<u8> = if args_len == 0 {
89
- Vec::new()
90
- } else {
91
- slice::from_raw_parts(args, args_len).to_vec()
92
- };
93
- u32::from(task::submit_op_into(
94
- op,
95
- owned,
96
- task_id as u64,
97
- out as usize,
98
- out_cap,
99
- ))
93
+ panic_guard(
94
+ || {
95
+ let owned: Vec<u8> = if args_len == 0 {
96
+ Vec::new()
97
+ } else {
98
+ slice::from_raw_parts(args, args_len).to_vec()
99
+ };
100
+ u32::from(task::submit_op_into(
101
+ op,
102
+ owned,
103
+ task_id as u64,
104
+ out as usize,
105
+ out_cap,
106
+ ))
107
+ },
108
+ 0,
109
+ )
100
110
  }
101
111
 
102
112
  /// Mark `task_id` cancelled. Returns `1` when newly marked, `0` otherwise.
@@ -129,20 +139,25 @@ pub unsafe extern "C" fn castrum_task_submit_slice(
129
139
  if (hdr.is_null() && hdr_len != 0) || (data.is_null() && data_len != 0) {
130
140
  return 0;
131
141
  }
132
- // The header is small, so copying it keeps the caller free to reuse a
133
- // scratch buffer; the payload is never copied.
134
- let owned = if hdr_len == 0 {
135
- Vec::new()
136
- } else {
137
- slice::from_raw_parts(hdr, hdr_len).to_vec()
138
- };
139
- u32::from(task::submit_slice_op(
140
- op,
141
- owned,
142
- data as usize,
143
- data_len,
144
- task_id as u64,
145
- ))
142
+ panic_guard(
143
+ || {
144
+ // The header is small, so copying it keeps the caller free to reuse a
145
+ // scratch buffer; the payload is never copied.
146
+ let owned = if hdr_len == 0 {
147
+ Vec::new()
148
+ } else {
149
+ slice::from_raw_parts(hdr, hdr_len).to_vec()
150
+ };
151
+ u32::from(task::submit_slice_op(
152
+ op,
153
+ owned,
154
+ data as usize,
155
+ data_len,
156
+ task_id as u64,
157
+ ))
158
+ },
159
+ 0,
160
+ )
146
161
  }
147
162
 
148
163
  /// Register the thread-safe doorbell trampoline address (`0` disables it).
@@ -158,8 +173,12 @@ pub extern "C" fn castrum_task_set_doorbell(cb: *const std::os::raw::c_void) ->
158
173
  }
159
174
 
160
175
  /// Stop the pool and drop pending completions. Returns `0`.
176
+ ///
177
+ /// The doorbell trampoline is cleared FIRST: a worker completing after this
178
+ /// point must not call a JS callback the owner is about to close.
161
179
  #[no_mangle]
162
180
  pub extern "C" fn castrum_task_shutdown() -> u32 {
181
+ task::completion::clear_doorbell();
163
182
  task::shutdown();
164
183
  task::completion::clear();
165
184
  0
package/rust/ffi/tests.rs CHANGED
@@ -189,6 +189,30 @@ fn utf8_valid_c_abi() {
189
189
  assert_eq!(unsafe { castrum_utf8_valid(b"".as_ptr(), 0) }, 1);
190
190
  }
191
191
 
192
+ #[test]
193
+ fn borrowed_hmac_key_preserves_signatures_after_eviction() {
194
+ HMAC_KEY_CACHE.with(|c| c.borrow_mut().clear());
195
+ for i in 0..(HMAC_KEY_CACHE_CAP * 3) {
196
+ let secret = format!("borrowed-key-{}", i % (HMAC_KEY_CACHE_CAP + 1));
197
+ let expected = hmac::sign(&hmac_key(secret.as_bytes()), b"payload");
198
+ for _ in 0..2 {
199
+ let actual = with_hmac_key_cached(secret.as_bytes(), |key| hmac::sign(key, b"payload"));
200
+ assert_eq!(actual.as_ref(), expected.as_ref());
201
+ }
202
+ }
203
+ }
204
+
205
+ #[test]
206
+ fn borrowed_hmac_key_releases_cache_on_unwind() {
207
+ let caught = std::panic::catch_unwind(|| {
208
+ with_hmac_key_cached(b"borrow-panic", |_| panic!("test callback panic"));
209
+ });
210
+ assert!(caught.is_err());
211
+ let actual = with_hmac_key_cached(b"borrow-panic", |key| hmac::sign(key, b"payload"));
212
+ let expected = hmac::sign(&hmac_key(b"borrow-panic"), b"payload");
213
+ assert_eq!(actual.as_ref(), expected.as_ref());
214
+ }
215
+
192
216
  #[test]
193
217
  fn hmac_key_cache_reuses_compiled_key() {
194
218
  // Same-secret calls must hit the cache (the whole point of the LRU):
@@ -2308,6 +2332,179 @@ fn wire_validate_and_session_c_abi() {
2308
2332
  unsafe { castrum_session_open(token, c"wrong".as_ptr(), buf.as_mut_ptr(), buf.len()) },
2309
2333
  0
2310
2334
  );
2335
+
2336
+ // ── Byte-arg siblings ──────────────────────────────────────────
2337
+ // Copy the first token OUT of the shared per-thread CSTR_BUF before the
2338
+ // next `cstring` return reuses it (that buffer is one-per-thread by design).
2339
+ let token_str = unsafe { std::ffi::CStr::from_ptr(token) }
2340
+ .to_string_lossy()
2341
+ .into_owned();
2342
+ let id = b"sess-9";
2343
+ let data = b"{\"n\":1}";
2344
+ let secret = b"sekrit";
2345
+ let token_b = unsafe {
2346
+ castrum_session_seal_bytes(
2347
+ id.as_ptr(),
2348
+ id.len(),
2349
+ data.as_ptr(),
2350
+ data.len(),
2351
+ 1_234_567,
2352
+ secret.as_ptr(),
2353
+ secret.len(),
2354
+ )
2355
+ };
2356
+ assert!(!token_b.is_null());
2357
+ // One shared core ⇒ byte-identical token. This is the parity guarantee that
2358
+ // makes the byte form a drop-in for the cstring form.
2359
+ let token_b_str = unsafe { std::ffi::CStr::from_ptr(token_b) }
2360
+ .to_string_lossy()
2361
+ .into_owned();
2362
+ assert_eq!(token_b_str, token_str);
2363
+
2364
+ let tok_bytes = token_b_str.as_bytes();
2365
+ let mut buf2 = [0u8; 256];
2366
+ let w2 = unsafe {
2367
+ castrum_session_open_bytes(
2368
+ tok_bytes.as_ptr(),
2369
+ tok_bytes.len(),
2370
+ secret.as_ptr(),
2371
+ secret.len(),
2372
+ buf2.as_mut_ptr(),
2373
+ buf2.len(),
2374
+ )
2375
+ };
2376
+ assert_eq!(w2, w);
2377
+ assert_eq!(buf2[..w2], buf[..w]);
2378
+ // bad signature → 0
2379
+ assert_eq!(
2380
+ unsafe {
2381
+ castrum_session_open_bytes(
2382
+ tok_bytes.as_ptr(),
2383
+ tok_bytes.len(),
2384
+ b"wrong".as_ptr(),
2385
+ 5,
2386
+ buf2.as_mut_ptr(),
2387
+ buf2.len(),
2388
+ )
2389
+ },
2390
+ 0
2391
+ );
2392
+ // Too-small buffer → the EXACT required size, not 0 (needed-size rule).
2393
+ assert_eq!(
2394
+ unsafe {
2395
+ castrum_session_open_bytes(
2396
+ tok_bytes.as_ptr(),
2397
+ tok_bytes.len(),
2398
+ secret.as_ptr(),
2399
+ secret.len(),
2400
+ buf2.as_mut_ptr(),
2401
+ 4,
2402
+ )
2403
+ },
2404
+ w2
2405
+ );
2406
+
2407
+ // The reason this sibling exists: the cstring form TRUNCATES at an embedded
2408
+ // NUL (the engine's transcode is NUL-terminated), so an id carrying one is
2409
+ // sealed under its truncated form. The byte form carries the exact length.
2410
+ //
2411
+ // Each token is copied to a String IMMEDIATELY after its own call: both
2412
+ // `cstring` returns write into ONE per-thread CSTR_BUF, so the earlier
2413
+ // pointer is dead the moment the next call reuses it — the aliasing hazard
2414
+ // `docs/FFI_BUN_GUIDE.md` documents. The assertions below also read the
2415
+ // recovered `id` BYTES rather than the token's text, so they hold
2416
+ // regardless of how the payload is wire-encoded.
2417
+ let nul_id = b"sess-9\0extra";
2418
+ let tp = unsafe {
2419
+ castrum_session_seal(
2420
+ nul_id.as_ptr() as *const std::os::raw::c_char,
2421
+ c"{\"n\":1}".as_ptr(),
2422
+ 1_234_567,
2423
+ c"sekrit".as_ptr(),
2424
+ )
2425
+ };
2426
+ assert!(!tp.is_null());
2427
+ let truncated_str = unsafe { std::ffi::CStr::from_ptr(tp) }
2428
+ .to_string_lossy()
2429
+ .into_owned();
2430
+
2431
+ let ep = unsafe {
2432
+ castrum_session_seal_bytes(
2433
+ nul_id.as_ptr(),
2434
+ nul_id.len(),
2435
+ data.as_ptr(),
2436
+ data.len(),
2437
+ 1_234_567,
2438
+ secret.as_ptr(),
2439
+ secret.len(),
2440
+ )
2441
+ };
2442
+ assert!(!ep.is_null());
2443
+ let exact_str = unsafe { std::ffi::CStr::from_ptr(ep) }
2444
+ .to_string_lossy()
2445
+ .into_owned();
2446
+
2447
+ assert_ne!(
2448
+ exact_str, truncated_str,
2449
+ "byte form must not truncate at NUL"
2450
+ );
2451
+
2452
+ // Open each token and read back the `id` from the packed layout
2453
+ // (`[u8 ok][i64 exp][u32 idLen][id]…`) — the ground truth this change is
2454
+ // about. NOTE: `seal_core` JSON-escapes the id but `open_core` returns the
2455
+ // raw captured span, so an id needing escapes comes back escaped. That
2456
+ // asymmetry is pre-existing; these assertions are written not to depend on
2457
+ // it, only on the truncation itself.
2458
+ let id_of = |tok: &str, out: &mut [u8; 256]| -> Vec<u8> {
2459
+ let w = unsafe {
2460
+ castrum_session_open_bytes(
2461
+ tok.as_ptr(),
2462
+ tok.len(),
2463
+ secret.as_ptr(),
2464
+ secret.len(),
2465
+ out.as_mut_ptr(),
2466
+ out.len(),
2467
+ )
2468
+ };
2469
+ assert!(w > 13, "open of {tok:?} failed");
2470
+ let id_len = u32::from_le_bytes([out[9], out[10], out[11], out[12]]) as usize;
2471
+ out[13..13 + id_len].to_vec()
2472
+ };
2473
+ let mut scratch = [0u8; 256];
2474
+ let truncated_id = id_of(&truncated_str, &mut scratch);
2475
+ let exact_id = id_of(&exact_str, &mut scratch);
2476
+ assert_eq!(truncated_id, b"sess-9");
2477
+ // The byte form carries the exact length, so the id comes back WHOLE — and
2478
+ // byte-exact, because `open_core` now decodes the id rather than returning
2479
+ // the escaped span.
2480
+ assert_eq!(exact_id, &nul_id[..]);
2481
+
2482
+ // Null pointers are rejected, never dereferenced.
2483
+ assert!(unsafe {
2484
+ castrum_session_seal_bytes(
2485
+ std::ptr::null(),
2486
+ 0,
2487
+ std::ptr::null(),
2488
+ 0,
2489
+ 0,
2490
+ std::ptr::null(),
2491
+ 0,
2492
+ )
2493
+ }
2494
+ .is_null());
2495
+ assert_eq!(
2496
+ unsafe {
2497
+ castrum_session_open_bytes(
2498
+ std::ptr::null(),
2499
+ 0,
2500
+ secret.as_ptr(),
2501
+ secret.len(),
2502
+ buf2.as_mut_ptr(),
2503
+ buf2.len(),
2504
+ )
2505
+ },
2506
+ 0
2507
+ );
2311
2508
  }
2312
2509
 
2313
2510
  #[test]
package/rust/ffi/util.rs CHANGED
@@ -39,6 +39,7 @@ thread_local! {
39
39
 
40
40
  /// Return a compiled HMAC-SHA256 key for `secret`, reusing a cached copy when
41
41
  /// the same secret was used recently (per-thread LRU, cap 16).
42
+ #[cfg(test)]
42
43
  #[inline]
43
44
  pub(crate) fn hmac_key_cached(secret: &[u8]) -> hmac::Key {
44
45
  HMAC_KEY_CACHE.with(|cache| {
@@ -49,11 +50,50 @@ pub(crate) fn hmac_key_cached(secret: &[u8]) -> hmac::Key {
49
50
  return key.clone();
50
51
  }
51
52
  let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
53
+ // Zeroize the secret bytes of the entry we are about to evict: the raw
54
+ // key otherwise lingers in the per-thread LRU until eviction.
55
+ if cache.len() >= HMAC_KEY_CACHE_CAP {
56
+ if let Some((mut evicted, _)) = cache.pop_lru() {
57
+ evicted.iter_mut().for_each(|b| *b = 0);
58
+ }
59
+ }
52
60
  cache.put(secret.to_vec(), key.clone());
53
61
  key
54
62
  })
55
63
  }
56
64
 
65
+ /// Run `f` with a reference to the cached compiled key for `secret` WITHOUT
66
+ /// cloning it out of the per-thread LRU (the clone deep-copies the aws-lc
67
+ /// `HMAC_CTX` on every hit). The borrow lives only inside `f`; the cache entry
68
+ /// stays owned by the cache. The callback must not re-enter this cache:
69
+ /// its mutable RefCell borrow is held until `f` returns. If `f` panics,
70
+ /// the borrow is released by unwinding (no poisoning).
71
+ ///
72
+ /// On a MISS the key is compiled, inserted, and passed by reference — same
73
+ /// one-time cost as before.
74
+ #[inline]
75
+ pub(crate) fn with_hmac_key_cached<T>(secret: &[u8], f: impl FnOnce(&hmac::Key) -> T) -> T {
76
+ HMAC_KEY_CACHE.with(|cache| {
77
+ let mut cache = cache.borrow_mut();
78
+ if let Some(key) = cache.get(secret) {
79
+ #[cfg(test)]
80
+ HMAC_CACHE_HITS.with(|h| h.set(h.get() + 1));
81
+ return f(key);
82
+ }
83
+ let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
84
+ // Zeroize the raw secret bytes of the evicted entry (the compiled key's
85
+ // ctx is already wiped on drop by aws-lc-rs; the cache KEY is the raw
86
+ // secret Vec, which would otherwise linger).
87
+ if cache.len() >= HMAC_KEY_CACHE_CAP {
88
+ if let Some((mut evicted, _)) = cache.pop_lru() {
89
+ evicted.iter_mut().for_each(|b| *b = 0);
90
+ }
91
+ }
92
+ cache.put(secret.to_vec(), key.clone());
93
+ f(&key)
94
+ })
95
+ }
96
+
57
97
  /// Map an algorithm selector byte to the AEAD `Algorithm` (0 = AES-256-GCM,
58
98
  /// 1 = ChaCha20-Poly1305) — mirrors `crate::crypto::aead::resolve_algorithm`.
59
99
  #[inline]
@@ -216,6 +216,19 @@ mod tests {
216
216
  assert_eq!(h.xff(), Some(&b"1.2.3.4"[..]));
217
217
  }
218
218
 
219
+ #[test]
220
+ fn headers_parse_keeps_the_last_duplicate_xff() {
221
+ // Documented behavior (docs/INGRESS.md trustProxy row): the parser
222
+ // overwrites on each occurrence, so only the LAST X-Forwarded-For line
223
+ // is read. A trusted-edge deployment must append only to that line.
224
+ let packed = pack_headers([
225
+ ("X-Forwarded-For", "10.0.0.1"),
226
+ ("X-Forwarded-For", "203.0.113.9"),
227
+ ]);
228
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
229
+ assert_eq!(h.xff(), Some(&b"203.0.113.9"[..]));
230
+ }
231
+
219
232
  #[test]
220
233
  fn headers_parse_case_insensitive_names() {
221
234
  let packed = pack_headers([("ORIGIN", "https://x.io")]);
@@ -356,6 +356,90 @@ pub fn parts_to_packed(parts: &[Part<'_>], out: &mut Vec<u8>) {
356
356
  }
357
357
  }
358
358
 
359
+ /// Packed size of `parts` per `parts_to_packed`'s layout (fixed header +
360
+ /// per-part fields), saturating — used by the C-ABI direct-write path to
361
+ /// size-check the caller's buffer BEFORE any write (no partial output).
362
+ #[inline]
363
+ pub fn parts_packed_len(parts: &[Part<'_>]) -> usize {
364
+ // [u32 count] + per part: [u32 name_len][name] + [u8 has_filename]
365
+ // ([u32 filename_len][filename] | [u32 0]) + [u8 has_ct]
366
+ // ([u32 ct_len][ct] | [u32 0]) + [u32 data_len][data].
367
+ // The has_* flags are ONE byte each (the Vec path `push`es them) — this
368
+ // must stay in lockstep with `parts_to_packed_into`'s writer below.
369
+ let mut len = 4usize;
370
+ for p in parts {
371
+ len += 4 + p.name.len();
372
+ len += 1 + match p.filename {
373
+ Some(f) => 4 + f.len(),
374
+ None => 4,
375
+ };
376
+ len += 1 + match p.content_type {
377
+ Some(c) => 4 + c.len(),
378
+ None => 4,
379
+ };
380
+ len += 4 + p.data.len();
381
+ }
382
+ len
383
+ }
384
+
385
+ /// Write `parts` into `out` exactly as [`parts_to_packed`] would, directly
386
+ /// into a caller-provided slice (no intermediate `Vec`, no final memcpy).
387
+ /// Returns bytes written, or `None` when `out` is smaller than needed (the
388
+ /// caller then sizes via [`parts_packed_len`] and retries — needed-size
389
+ /// convention; no partial bytes are written).
390
+ pub fn parts_to_packed_into(parts: &[Part<'_>], out: &mut [u8]) -> Option<usize> {
391
+ let needed = parts_packed_len(parts);
392
+ if needed > out.len() {
393
+ return None;
394
+ }
395
+ let mut w = 0usize;
396
+ macro_rules! put32 {
397
+ ($v:expr) => {{
398
+ out[w..w + 4].copy_from_slice(&($v as u32).to_le_bytes());
399
+ w += 4;
400
+ }};
401
+ }
402
+ put32!(parts.len());
403
+ for p in parts {
404
+ put32!(p.name.len());
405
+ out[w..w + p.name.len()].copy_from_slice(p.name);
406
+ w += p.name.len();
407
+ match p.filename {
408
+ Some(f) => {
409
+ out[w] = 1;
410
+ w += 1;
411
+ put32!(f.len());
412
+ out[w..w + f.len()].copy_from_slice(f);
413
+ w += f.len();
414
+ }
415
+ None => {
416
+ out[w] = 0;
417
+ w += 1;
418
+ put32!(0);
419
+ }
420
+ }
421
+ match p.content_type {
422
+ Some(c) => {
423
+ out[w] = 1;
424
+ w += 1;
425
+ put32!(c.len());
426
+ out[w..w + c.len()].copy_from_slice(c);
427
+ w += c.len();
428
+ }
429
+ None => {
430
+ out[w] = 0;
431
+ w += 1;
432
+ put32!(0);
433
+ }
434
+ }
435
+ put32!(p.data.len());
436
+ out[w..w + p.data.len()].copy_from_slice(p.data);
437
+ w += p.data.len();
438
+ }
439
+ debug_assert_eq!(w, needed);
440
+ Some(needed)
441
+ }
442
+
359
443
  /// Parallel multipart parse batch: packed `[u32 count]{[u32 len][body]}` in →
360
444
  /// packed `[u32 count]{[u32 len][parts_packed]}` out (same boundary for all).
361
445
  #[napi]
@@ -453,6 +537,61 @@ mod tests {
453
537
  assert_eq!(packed[8 + name_len], 1); // has filename
454
538
  }
455
539
 
540
+ #[test]
541
+ fn parts_to_packed_into_matches_vec_path() {
542
+ // The direct-write core must be byte-identical to the Vec-based
543
+ // serializer for every corpus shape (field-only, file, multi-part).
544
+ type Shape<'a> = (&'a [u8], Option<&'a [u8]>, Option<&'a [u8]>, &'a [u8]);
545
+ let shapes: Vec<Vec<Shape<'_>>> = vec![
546
+ vec![(b"a", None, None, b"1")],
547
+ vec![(
548
+ b"file",
549
+ Some(b"report.pdf"),
550
+ Some(b"application/pdf"),
551
+ b"BBBB",
552
+ )],
553
+ vec![(b"x", None, None, b""), (b"y", Some(b"y.bin"), None, b"zz")],
554
+ // Static multi-part corpus with varying field widths (no
555
+ // generated/borrowed temporaries — every slice is a literal).
556
+ vec![
557
+ (b"name_a" as &[u8], None, None, b"AAAAA" as &[u8]),
558
+ (b"n2", Some(b"n2.bin"), Some(b"text/plain"), b"BBBBBBBBBB"),
559
+ (b"n3", None, Some(b"application/json"), b"C"),
560
+ ],
561
+ ];
562
+ for parts in &shapes {
563
+ let owned: Vec<Part<'_>> = parts
564
+ .iter()
565
+ .map(|(n, f, c, d)| Part {
566
+ name: n,
567
+ filename: *f,
568
+ content_type: *c,
569
+ data: d,
570
+ })
571
+ .collect();
572
+ let mut expected = Vec::new();
573
+ parts_to_packed(&owned, &mut expected);
574
+ let mut direct = vec![0u8; expected.len() + 7];
575
+ let written = parts_to_packed_into(&owned, &mut direct).expect("fits");
576
+ assert_eq!(written, expected.len());
577
+ assert_eq!(&direct[..written], &expected[..]);
578
+ // Too-small buffer → None, no partial writes.
579
+ let mut small = vec![0u8; expected.len() - 1];
580
+ assert_eq!(parts_to_packed_into(&owned, &mut small), None);
581
+ assert!(small.iter().all(|&b| b == 0));
582
+ }
583
+ }
584
+
585
+ #[test]
586
+ fn parts_packed_len_matches_parts_to_packed() {
587
+ let mut b = body("f", Some("a.bin"), Some("text/plain"), b"data123");
588
+ b.extend_from_slice(b"--\r\n");
589
+ let parts = parse_multipart(&b, BOUNDARY);
590
+ let mut expected = Vec::new();
591
+ parts_to_packed(&parts, &mut expected);
592
+ assert_eq!(parts_packed_len(&parts), expected.len());
593
+ }
594
+
456
595
  #[test]
457
596
  fn parses_file_with_filename_and_type() {
458
597
  let mut b = body(