castrum 0.9.7 → 0.9.9

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 (108) 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 +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -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/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -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 +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  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 +55 -35
  35. package/rust/ffi/tests.rs +24 -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/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
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
@@ -18,7 +18,8 @@ pub extern "C" fn castrum_task_init(threads: u32) -> u32 {
18
18
  }
19
19
 
20
20
  /// Submit `op` with `args_len` packed arg bytes, completing under `task_id`.
21
- /// Returns `1` accepted, `0` rejected (null args with a non-zero length).
21
+ /// Returns `1` accepted, `2` overloaded (the bounded work queue is full), or
22
+ /// `0` invalid args (null args with a non-zero length) / panic.
22
23
  ///
23
24
  /// # Safety
24
25
  /// `args` must be valid for reads of `args_len` bytes.
@@ -32,12 +33,21 @@ pub unsafe extern "C" fn castrum_task_submit(
32
33
  if args.is_null() && args_len != 0 {
33
34
  return 0;
34
35
  }
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))
36
+ panic_guard(
37
+ || {
38
+ let owned: Vec<u8> = if args_len == 0 {
39
+ Vec::new()
40
+ } else {
41
+ slice::from_raw_parts(args, args_len).to_vec()
42
+ };
43
+ if task::submit_op(op, owned, task_id as u64) {
44
+ 1
45
+ } else {
46
+ 2
47
+ }
48
+ },
49
+ 0,
50
+ )
41
51
  }
42
52
 
43
53
  /// Drain finished tasks into `out` (packed `[u32 count][entry…]`).
@@ -67,7 +77,7 @@ pub extern "C" fn castrum_task_pending() -> u32 {
67
77
  /// (UNSUPPORTED) means the op has no `_into` form and the caller should fall
68
78
  /// back to `castrum_task_submit`.
69
79
  ///
70
- /// Returns `1` accepted, `0` rejected.
80
+ /// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
71
81
  ///
72
82
  /// # Safety
73
83
  /// `args` must be valid for reads of `args_len` bytes. `out` must be valid for
@@ -85,18 +95,21 @@ pub unsafe extern "C" fn castrum_task_submit_out(
85
95
  if (args.is_null() && args_len != 0) || out.is_null() || out_cap == 0 {
86
96
  return 0;
87
97
  }
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
- ))
98
+ panic_guard(
99
+ || {
100
+ let owned: Vec<u8> = if args_len == 0 {
101
+ Vec::new()
102
+ } else {
103
+ slice::from_raw_parts(args, args_len).to_vec()
104
+ };
105
+ if task::submit_op_into(op, owned, task_id as u64, out as usize, out_cap) {
106
+ 1
107
+ } else {
108
+ 2
109
+ }
110
+ },
111
+ 0,
112
+ )
100
113
  }
101
114
 
102
115
  /// Mark `task_id` cancelled. Returns `1` when newly marked, `0` otherwise.
@@ -111,7 +124,7 @@ pub extern "C" fn castrum_task_cancel(task_id: usize) -> u32 {
111
124
  /// compression costs a full-size copy on the JS thread and measured WORSE than
112
125
  /// the synchronous built-in (docs/RND-CONCURRENCY.md §7b).
113
126
  ///
114
- /// Returns `1` accepted, `0` rejected.
127
+ /// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
115
128
  ///
116
129
  /// # Safety
117
130
  /// `hdr` must be valid for reads of `hdr_len` bytes. `data` must be valid for
@@ -129,20 +142,23 @@ pub unsafe extern "C" fn castrum_task_submit_slice(
129
142
  if (hdr.is_null() && hdr_len != 0) || (data.is_null() && data_len != 0) {
130
143
  return 0;
131
144
  }
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
- ))
145
+ panic_guard(
146
+ || {
147
+ // The header is small, so copying it keeps the caller free to reuse a
148
+ // scratch buffer; the payload is never copied.
149
+ let owned = if hdr_len == 0 {
150
+ Vec::new()
151
+ } else {
152
+ slice::from_raw_parts(hdr, hdr_len).to_vec()
153
+ };
154
+ if task::submit_slice_op(op, owned, data as usize, data_len, task_id as u64) {
155
+ 1
156
+ } else {
157
+ 2
158
+ }
159
+ },
160
+ 0,
161
+ )
146
162
  }
147
163
 
148
164
  /// Register the thread-safe doorbell trampoline address (`0` disables it).
@@ -158,8 +174,12 @@ pub extern "C" fn castrum_task_set_doorbell(cb: *const std::os::raw::c_void) ->
158
174
  }
159
175
 
160
176
  /// Stop the pool and drop pending completions. Returns `0`.
177
+ ///
178
+ /// The doorbell trampoline is cleared FIRST: a worker completing after this
179
+ /// point must not call a JS callback the owner is about to close.
161
180
  #[no_mangle]
162
181
  pub extern "C" fn castrum_task_shutdown() -> u32 {
182
+ task::completion::clear_doorbell();
163
183
  task::shutdown();
164
184
  task::completion::clear();
165
185
  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(