castrum 0.9.7 → 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 (84) hide show
  1. package/Cargo.lock +1 -1
  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 +1648 -1325
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +1 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  20. package/index.ts +1 -0
  21. package/package.json +3 -2
  22. package/rust/crypto/aead.rs +44 -0
  23. package/rust/crypto/argon2.rs +32 -2
  24. package/rust/crypto/jwt/tests.rs +19 -0
  25. package/rust/crypto/jwt/token.rs +43 -28
  26. package/rust/crypto/session.rs +6 -0
  27. package/rust/ffi/crypto.rs +63 -32
  28. package/rust/ffi/hashing.rs +48 -33
  29. package/rust/ffi/http.rs +62 -36
  30. package/rust/ffi/metrics.rs +6 -2
  31. package/rust/ffi/payload.rs +47 -27
  32. package/rust/ffi/task.rs +51 -32
  33. package/rust/ffi/tests.rs +24 -0
  34. package/rust/ffi/util.rs +40 -0
  35. package/rust/http/headers.rs +13 -0
  36. package/rust/http/multipart.rs +139 -0
  37. package/rust/ingress/native_route.rs +21 -3
  38. package/rust/task/completion.rs +7 -0
  39. package/rust/task/mod.rs +1 -1
  40. package/rust/task/tests.rs +30 -0
  41. package/src/ingress/decode/baked-result.ts +13 -5
  42. package/src/ingress/decode/fast-result.ts +1 -1
  43. package/src/ingress/decode/result-base.ts +16 -2
  44. package/src/ingress/handlers.ts +103 -31
  45. package/src/ingress/headers/memoized-headers.ts +40 -0
  46. package/src/ingress/native-route.ts +25 -6
  47. package/src/ingress/options.ts +26 -2
  48. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  49. package/src/ingress/response/baked-response.ts +118 -28
  50. package/src/ingress/router.ts +14 -2
  51. package/src/ingress/routes/common.ts +26 -10
  52. package/src/ingress/routes/echo.ts +50 -7
  53. package/src/ingress/routes/json-write.ts +21 -8
  54. package/src/ingress/routes/responder.ts +4 -4
  55. package/src/ingress/server-node.ts +33 -14
  56. package/src/ingress/server.ts +3 -4
  57. package/src/ingress/shared.ts +1 -1
  58. package/src/ingress/types.ts +15 -0
  59. package/src/loader/ops.ts +1 -1
  60. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  61. package/src/native/ffi/build/codecs.ts +0 -296
  62. package/src/native/ffi/build/instances-selftest.ts +264 -0
  63. package/src/native/ffi/build/instances.ts +10 -259
  64. package/src/native/ffi/build/parse-selftest.ts +238 -0
  65. package/src/native/ffi/build/parse.ts +11 -234
  66. package/src/native/ffi/build.ts +5 -1
  67. package/src/native/ffi/selftest.ts +3 -3
  68. package/src/native/ffi/symbols.ts +460 -0
  69. package/src/native/ffi.ts +2 -434
  70. package/src/native/ingress-binding.ts +39 -0
  71. package/src/runtime/codec.ts +24 -0
  72. package/src/runtime/types.ts +11 -0
  73. package/src/rust-ffi/batch/build.ts +18 -8
  74. package/src/rust-ffi/batch/types.ts +15 -0
  75. package/src/rust-ffi/options.ts +19 -0
  76. package/src/rust-ffi/scalar/crypto.ts +8 -0
  77. package/src/rust-ffi/scalar/factories.ts +47 -20
  78. package/src/rust-ffi/scalar/http.ts +60 -10
  79. package/src/rust-ffi/scalar/interface.ts +4 -1
  80. package/src/rust-ffi/scalar/payload.ts +27 -1
  81. package/src/shared/buffer-pool.ts +44 -9
  82. package/src/shared/codec.ts +26 -0
  83. package/src/shared/packed/parsers.ts +64 -7
  84. package/src/task/runtime.ts +29 -28
package/rust/ffi/http.rs CHANGED
@@ -446,7 +446,11 @@ pub unsafe extern "C" fn castrum_url_encode_query(
446
446
  return None;
447
447
  }
448
448
  let count = u32::from_le_bytes([input[0], input[1], input[2], input[3]]) as usize;
449
- let mut map = std::collections::BTreeMap::new();
449
+ // Borrow the keys/values as &str straight from the input slice
450
+ // instead of .to_string()-copying each into the map — the map is
451
+ // consumed inside this closure, so the borrows outlive it; the
452
+ // BTreeMap ordering (byte-lexicographic) is identical for &str.
453
+ let mut map = std::collections::BTreeMap::<&str, &str>::new();
450
454
  let mut off = 4usize;
451
455
  for _ in 0..count {
452
456
  if off + 4 > input.len() {
@@ -462,9 +466,7 @@ pub unsafe extern "C" fn castrum_url_encode_query(
462
466
  if off + klen > input.len() {
463
467
  return None;
464
468
  }
465
- let key = std::str::from_utf8(&input[off..off + klen])
466
- .ok()?
467
- .to_string();
469
+ let key = std::str::from_utf8(&input[off..off + klen]).ok()?;
468
470
  off += klen;
469
471
  if off + 4 > input.len() {
470
472
  return None;
@@ -479,9 +481,7 @@ pub unsafe extern "C" fn castrum_url_encode_query(
479
481
  if off + vlen > input.len() {
480
482
  return None;
481
483
  }
482
- let val = std::str::from_utf8(&input[off..off + vlen])
483
- .ok()?
484
- .to_string();
484
+ let val = std::str::from_utf8(&input[off..off + vlen]).ok()?;
485
485
  off += vlen;
486
486
  map.insert(key, val);
487
487
  }
@@ -630,11 +630,18 @@ pub unsafe extern "C" fn castrum_http_parse_request_packed(
630
630
  if data.is_null() || out.is_null() {
631
631
  return 0;
632
632
  }
633
- crate::http::http_parser::http_parse_request_packed_into_slice(
634
- slice::from_raw_parts(data, len),
635
- slice::from_raw_parts_mut(out, out_cap),
633
+ // Attacker-controlled request bytes: a panic must become 0, not unwind
634
+ // through the C ABI.
635
+ panic_guard(
636
+ || {
637
+ crate::http::http_parser::http_parse_request_packed_into_slice(
638
+ slice::from_raw_parts(data, len),
639
+ slice::from_raw_parts_mut(out, out_cap),
640
+ )
641
+ .unwrap_or(0)
642
+ },
643
+ 0,
636
644
  )
637
- .unwrap_or(0)
638
645
  }
639
646
 
640
647
  /// Query string parse → packed output into `out`.
@@ -657,14 +664,19 @@ pub unsafe extern "C" fn castrum_query_parse_packed(
657
664
  if data.is_null() || out.is_null() {
658
665
  return 0;
659
666
  }
660
- let input = slice::from_raw_parts(data, len);
661
- let output = slice::from_raw_parts_mut(out, out_cap);
662
- match crate::http::query_parser::query_parse_packed_into_slice(input, output) {
663
- Ok(w) => w,
664
- // Too-small OR malformed — the size pass disambiguates: it parses the
665
- // same input, so Ok(needed) ⇒ too-small, Err ⇒ malformed (real error).
666
- Err(_) => crate::http::query_parser::query_parse_packed_size(input).unwrap_or(0),
667
- }
667
+ panic_guard(
668
+ || {
669
+ let input = slice::from_raw_parts(data, len);
670
+ let output = slice::from_raw_parts_mut(out, out_cap);
671
+ match crate::http::query_parser::query_parse_packed_into_slice(input, output) {
672
+ Ok(w) => w,
673
+ // Too-small OR malformed — the size pass disambiguates: it parses
674
+ // the same input, so Ok(needed) ⇒ too-small, Err ⇒ malformed.
675
+ Err(_) => crate::http::query_parser::query_parse_packed_size(input).unwrap_or(0),
676
+ }
677
+ },
678
+ 0,
679
+ )
668
680
  }
669
681
 
670
682
  /// Cookie header parse → packed output into `out`.
@@ -683,12 +695,17 @@ pub unsafe extern "C" fn castrum_cookie_parse_packed(
683
695
  if data.is_null() || out.is_null() {
684
696
  return 0;
685
697
  }
686
- let input = slice::from_raw_parts(data, len);
687
- let output = slice::from_raw_parts_mut(out, out_cap);
688
- match crate::http::cookie_parser::cookie_parse_packed_into_slice(input, output) {
689
- Ok(w) => w,
690
- Err(_) => crate::http::cookie_parser::cookie_parse_packed_size(input).unwrap_or(0),
691
- }
698
+ panic_guard(
699
+ || {
700
+ let input = slice::from_raw_parts(data, len);
701
+ let output = slice::from_raw_parts_mut(out, out_cap);
702
+ match crate::http::cookie_parser::cookie_parse_packed_into_slice(input, output) {
703
+ Ok(w) => w,
704
+ Err(_) => crate::http::cookie_parser::cookie_parse_packed_size(input).unwrap_or(0),
705
+ }
706
+ },
707
+ 0,
708
+ )
692
709
  }
693
710
 
694
711
  /// Parse an `application/x-www-form-urlencoded` body into packed pairs — the
@@ -730,23 +747,32 @@ pub unsafe extern "C" fn castrum_multipart_parse_packed(
730
747
  }
731
748
  // Wrap in panic_guard: the parser allocates internally — a panic must not
732
749
  // unwind through the C ABI (process crash); it becomes 0 instead.
733
- let packed = panic_guard(
750
+ // Direct write: needed-size first (no partial output), then serialize
751
+ // straight into the caller's buffer — no intermediate Vec, no final copy.
752
+ // Two-pass (parse → len check → write) keeps the needed-size convention
753
+ // without re-parsing: parts are borrowed slices, writable twice cheaply.
754
+ let parts = panic_guard(
734
755
  || {
735
- let parts = crate::http::multipart::parse_multipart_limited(
756
+ crate::http::multipart::parse_multipart_limited(
736
757
  slice::from_raw_parts(body, blen),
737
758
  slice::from_raw_parts(boundary, boundary_len),
738
759
  &Default::default(),
739
- );
740
- let mut buf = Vec::new();
741
- crate::http::multipart::parts_to_packed(&parts, &mut buf);
742
- buf
760
+ )
743
761
  },
744
762
  Vec::new(),
745
763
  );
746
- if packed.len() > out_cap {
747
- // Needed-size convention (see compress_to_out!): exact retry, no re-run.
748
- return packed.len();
764
+ let needed = panic_guard(|| crate::http::multipart::parts_packed_len(&parts), 0);
765
+ if needed == 0 || needed > out_cap {
766
+ return needed;
749
767
  }
750
- slice::from_raw_parts_mut(out, packed.len()).copy_from_slice(&packed);
751
- packed.len()
768
+ let written = panic_guard(
769
+ || {
770
+ crate::http::multipart::parts_to_packed_into(
771
+ &parts,
772
+ slice::from_raw_parts_mut(out, out_cap),
773
+ )
774
+ },
775
+ None,
776
+ );
777
+ written.unwrap_or(0)
752
778
  }
@@ -19,8 +19,12 @@ pub(crate) const METRICS_DECLARE_ERR: u32 = u32::MAX;
19
19
  /// Create an empty metrics registry → opaque handle (`0` = allocation panic).
20
20
  #[no_mangle]
21
21
  pub extern "C" fn castrum_metrics_create() -> usize {
22
- let handle = Box::into_raw(Box::new(crate::metrics::MetricsRegistry::new()));
23
- handle as usize
22
+ // An allocation panic must become the null-handle sentinel, not unwind
23
+ // through `extern "C"`.
24
+ panic_guard(
25
+ || Box::into_raw(Box::new(crate::metrics::MetricsRegistry::new())) as usize,
26
+ 0,
27
+ )
24
28
  }
25
29
 
26
30
  /// Declare a counter family → series id (`METRICS_DECLARE_ERR` on error).
@@ -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):
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(