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
@@ -9,7 +9,7 @@ use pbkdf2::pbkdf2_hmac;
9
9
  use sha2::Sha256;
10
10
  use std::slice;
11
11
 
12
- use super::util::{aead_alg, cstring_return, hmac_key_cached, panic_guard};
12
+ use super::util::{aead_alg, cstring_return, panic_guard, with_hmac_key_cached};
13
13
 
14
14
  /// HMAC-SHA256 verify → 1/0 (constant-time, hex sig compared after
15
15
  /// whitespace-trim — mirrors the napi scalar path).
@@ -32,8 +32,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256_verify(
32
32
  let Some(sig_bytes) = crate::util::bytes::hex_decode_32(sig_t) else {
33
33
  return 0;
34
34
  };
35
- let k = hmac_key_cached(slice::from_raw_parts(key, klen));
36
- u8::from(hmac::verify(&k, slice::from_raw_parts(data, dlen), &sig_bytes).is_ok())
35
+ with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
36
+ u8::from(hmac::verify(k, slice::from_raw_parts(data, dlen), &sig_bytes).is_ok())
37
+ })
37
38
  }
38
39
 
39
40
  /// CSRF constant-time verify → 1/0 (token format `<64-hex>.<64-hex>`).
@@ -50,11 +51,12 @@ pub unsafe extern "C" fn castrum_csrf_verify(
50
51
  if token.is_null() || secret.is_null() {
51
52
  return 0;
52
53
  }
53
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
54
- u8::from(crate::crypto::csrf::csrf_verify_with_key(
55
- slice::from_raw_parts(token, tlen),
56
- &k,
57
- ))
54
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
55
+ u8::from(crate::crypto::csrf::csrf_verify_with_key(
56
+ slice::from_raw_parts(token, tlen),
57
+ k,
58
+ ))
59
+ })
58
60
  }
59
61
 
60
62
  /// Argon2id password verify → 1/0 (constant-time internally).
@@ -71,10 +73,17 @@ pub unsafe extern "C" fn castrum_password_verify(
71
73
  if password.is_null() || phc.is_null() {
72
74
  return 0;
73
75
  }
74
- u8::from(crate::crypto::argon2::verify_password(
75
- slice::from_raw_parts(password, plen),
76
- slice::from_raw_parts(phc, phclen),
77
- ))
76
+ // The argon2 core allocates/derives — a panic must become 0, not unwind
77
+ // through `extern "C"`.
78
+ panic_guard(
79
+ || {
80
+ u8::from(crate::crypto::argon2::verify_password(
81
+ slice::from_raw_parts(password, plen),
82
+ slice::from_raw_parts(phc, phclen),
83
+ ))
84
+ },
85
+ 0,
86
+ )
78
87
  }
79
88
 
80
89
  /// bcrypt password verify → 1/0 (PHC `$2b$` string).
@@ -95,10 +104,15 @@ pub unsafe extern "C" fn castrum_password_verify_bcrypt(
95
104
  if password.is_null() || hash.is_null() {
96
105
  return 0;
97
106
  }
98
- let Ok(h) = std::ffi::CStr::from_ptr(hash).to_str() else {
99
- return 0;
100
- };
101
- u8::from(bcrypt::verify(slice::from_raw_parts(password, plen), h).unwrap_or(false))
107
+ panic_guard(
108
+ || {
109
+ let Ok(h) = std::ffi::CStr::from_ptr(hash).to_str() else {
110
+ return 0;
111
+ };
112
+ u8::from(bcrypt::verify(slice::from_raw_parts(password, plen), h).unwrap_or(false))
113
+ },
114
+ 0,
115
+ )
102
116
  }
103
117
 
104
118
  /// HMAC-SHA256 hex (64 chars) into `out`.
@@ -117,8 +131,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256(
117
131
  if key.is_null() || data.is_null() || out.is_null() || out_cap < 64 {
118
132
  return 0;
119
133
  }
120
- let k = hmac_key_cached(slice::from_raw_parts(key, klen));
121
- let tag = hmac::sign(&k, slice::from_raw_parts(data, dlen));
134
+ let tag = with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
135
+ hmac::sign(k, slice::from_raw_parts(data, dlen))
136
+ });
122
137
  let mut hex = [0u8; 64];
123
138
  crate::util::bytes::hex_encode_32(tag.as_ref(), &mut hex);
124
139
  slice::from_raw_parts_mut(out, 64).copy_from_slice(&hex);
@@ -141,9 +156,10 @@ pub unsafe extern "C" fn castrum_sign_cookie(
141
156
  return std::ptr::null();
142
157
  }
143
158
  let v = slice::from_raw_parts(value, vlen);
144
- let key = hmac_key_cached(slice::from_raw_parts(secret, slen));
145
159
  cstring_return(vlen + 65, |out| {
146
- crate::crypto::cookie_sign::sign_cookie_into(v, &key, out)
160
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
161
+ crate::crypto::cookie_sign::sign_cookie_into(v, key, out)
162
+ })
147
163
  })
148
164
  }
149
165
 
@@ -173,9 +189,14 @@ pub unsafe extern "C" fn castrum_sign_cookie_into(
173
189
  return needed;
174
190
  }
175
191
  let v = slice::from_raw_parts(value, vlen);
176
- let key = hmac_key_cached(slice::from_raw_parts(secret, slen));
177
- crate::crypto::cookie_sign::sign_cookie_into(v, &key, slice::from_raw_parts_mut(out, out_cap))
178
- .unwrap_or_default()
192
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
193
+ crate::crypto::cookie_sign::sign_cookie_into(
194
+ v,
195
+ key,
196
+ slice::from_raw_parts_mut(out, out_cap),
197
+ )
198
+ })
199
+ .unwrap_or_default()
179
200
  }
180
201
 
181
202
  /// Verify a signed cookie → the value without its signature, returned as a
@@ -195,9 +216,10 @@ pub unsafe extern "C" fn castrum_verify_cookie(
195
216
  return std::ptr::null();
196
217
  }
197
218
  let s = slice::from_raw_parts(signed, slen);
198
- let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
199
219
  cstring_return(slen, |out| {
200
- crate::crypto::cookie_sign::verify_cookie_into(s, &key, out)
220
+ with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
221
+ crate::crypto::cookie_sign::verify_cookie_into(s, key, out)
222
+ })
201
223
  })
202
224
  }
203
225
 
@@ -224,7 +246,6 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
224
246
  return 0;
225
247
  }
226
248
  let s = slice::from_raw_parts(signed, slen);
227
- let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
228
249
  // The value is a prefix of `signed` (everything before the last `.`), so
229
250
  // `slen` is always a sufficient bound — needed-size only differs when the
230
251
  // caller passed less.
@@ -237,8 +258,14 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
237
258
  }
238
259
  return slen;
239
260
  }
240
- crate::crypto::cookie_sign::verify_cookie_into(s, &key, slice::from_raw_parts_mut(out, out_cap))
241
- .unwrap_or_default()
261
+ with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
262
+ crate::crypto::cookie_sign::verify_cookie_into(
263
+ s,
264
+ key,
265
+ slice::from_raw_parts_mut(out, out_cap),
266
+ )
267
+ })
268
+ .unwrap_or_default()
242
269
  }
243
270
 
244
271
  /// CSRF token (`<64-hex(rnd)>.<64-hex(sig)>`, 129 bytes) returned as a
@@ -254,7 +281,6 @@ pub unsafe extern "C" fn castrum_csrf_token(
254
281
  if secret.is_null() {
255
282
  return std::ptr::null();
256
283
  }
257
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
258
284
  let mut rnd = [0u8; 32];
259
285
  if getrandom::fill(&mut rnd).is_err() {
260
286
  return std::ptr::null();
@@ -262,7 +288,10 @@ pub unsafe extern "C" fn castrum_csrf_token(
262
288
  let mut rnd_hex = [0u8; 64];
263
289
  crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
264
290
  let mut sig_hex = [0u8; 64];
265
- crate::util::bytes::hex_encode(hmac::sign(&k, &rnd_hex).as_ref(), &mut sig_hex);
291
+ let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
292
+ hmac::sign(k, &rnd_hex)
293
+ });
294
+ crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
266
295
  cstring_return(129, |out| {
267
296
  out[..64].copy_from_slice(&rnd_hex);
268
297
  out[64] = b'.';
@@ -291,7 +320,6 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
291
320
  if out_cap < 129 {
292
321
  return 129;
293
322
  }
294
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
295
323
  let mut rnd = [0u8; 32];
296
324
  if getrandom::fill(&mut rnd).is_err() {
297
325
  return 0;
@@ -299,7 +327,10 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
299
327
  let mut rnd_hex = [0u8; 64];
300
328
  crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
301
329
  let mut sig_hex = [0u8; 64];
302
- crate::util::bytes::hex_encode(hmac::sign(&k, &rnd_hex).as_ref(), &mut sig_hex);
330
+ let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
331
+ hmac::sign(k, &rnd_hex)
332
+ });
333
+ crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
303
334
  let o = slice::from_raw_parts_mut(out, 129);
304
335
  o[..64].copy_from_slice(&rnd_hex);
305
336
  o[64] = b'.';
@@ -625,6 +656,58 @@ pub unsafe extern "C" fn castrum_random_token_into(
625
656
 
626
657
  // ── Session envelope (fused JSON + HMAC) ────────────────────────────
627
658
 
659
+ /// Shared tail of both seal entry points: `None` (empty id/secret, or a panic)
660
+ /// → null pointer, otherwise clone the sealed token into the engine's
661
+ /// per-thread cstring buffer. Extracted so the cstring and byte forms cannot
662
+ /// diverge — the byte form exists only to change how the INPUTS cross.
663
+ fn sealed_to_cstr(sealed: Option<Vec<u8>>) -> *const std::os::raw::c_char {
664
+ let Some(tok) = sealed else {
665
+ return std::ptr::null();
666
+ };
667
+ super::util::cstring_return(tok.len(), move |buf| {
668
+ if buf.len() < tok.len() {
669
+ return None;
670
+ }
671
+ buf[..tok.len()].copy_from_slice(&tok);
672
+ Some(tok.len())
673
+ })
674
+ }
675
+
676
+ /// Shared tail of both open entry points: pack the extracted envelope into the
677
+ /// caller's buffer as `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`,
678
+ /// or report the EXACT size required when `out_cap` is too small. `None` (bad
679
+ /// signature / malformed envelope) → `0`. Never returns `0` on a valid call.
680
+ ///
681
+ /// # Safety
682
+ /// `out` must be valid for writes up to `out_cap`; the caller null-checks it.
683
+ unsafe fn pack_open_result(
684
+ opened: Option<(i64, Vec<u8>, Vec<u8>)>,
685
+ out: *mut u8,
686
+ out_cap: usize,
687
+ ) -> usize {
688
+ let Some((exp, id, data)) = opened else {
689
+ return 0;
690
+ };
691
+ // Layout size: 1 + 8 + 4+id + 4+data
692
+ let need = 1 + 8 + 4 + id.len() + 4 + data.len();
693
+ if need > out_cap {
694
+ return need;
695
+ }
696
+ let o = slice::from_raw_parts_mut(out, need);
697
+ o[0] = 1;
698
+ o[1..9].copy_from_slice(&exp.to_le_bytes());
699
+ let id_len = id.len() as u32;
700
+ o[9..13].copy_from_slice(&id_len.to_le_bytes());
701
+ let mut p = 13usize;
702
+ o[p..p + id.len()].copy_from_slice(&id);
703
+ p += id.len();
704
+ let d_len = data.len() as u32;
705
+ o[p..p + 4].copy_from_slice(&d_len.to_le_bytes());
706
+ p += 4;
707
+ o[p..p + data.len()].copy_from_slice(&data);
708
+ need
709
+ }
710
+
628
711
  /// Seal a session envelope NATIVELY: build
629
712
  /// `{"id":"…","data":<data_json>,"exp":exp}` and HMAC-sign it into the
630
713
  /// `payload.<64-hex>` cookie token — ONE crossing replaces the JS
@@ -650,16 +733,7 @@ pub unsafe extern "C" fn castrum_session_seal(
650
733
  || crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
651
734
  None,
652
735
  );
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
- })
736
+ sealed_to_cstr(sealed)
663
737
  }
664
738
 
665
739
  /// Open a sealed session token: verify the HMAC and extract the envelope in
@@ -684,25 +758,72 @@ pub unsafe extern "C" fn castrum_session_open(
684
758
  let tok = std::ffi::CStr::from_ptr(token).to_bytes();
685
759
  let sec = std::ffi::CStr::from_ptr(secret).to_bytes();
686
760
  let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
687
- let Some((exp, id, data)) = opened else {
761
+ pack_open_result(opened, out, out_cap)
762
+ }
763
+
764
+ /// Byte-arg sibling of `castrum_session_seal`: same core, same wire format, but
765
+ /// every input crosses as `(ptr, len)` instead of a `cstring` ARG.
766
+ ///
767
+ /// Per `docs/FFI_BUN_GUIDE.md` §6.1 there are two reasons, and only the second
768
+ /// cannot be worked around:
769
+ /// 1. a `cstring` ARG costs the engine a transcode into a call-scoped
770
+ /// NUL-terminated buffer (~80ns per arg through the public surface vs ~37ns
771
+ /// for the byte path);
772
+ /// 2. an embedded `U+0000` SILENTLY TRUNCATES the value before Rust sees it.
773
+ /// `id` and `data_json` are user-derived (the id is escaped by
774
+ /// `seal_core`, which only makes sense if it may contain arbitrary bytes),
775
+ /// so a session id carrying a NUL would be sealed under its truncated form.
776
+ /// This form carries the exact length and cannot be truncated.
777
+ ///
778
+ /// # Safety
779
+ /// `id`/`data_json`/`secret` must be valid for reads of their stated lengths;
780
+ /// null pointers are rejected (→ null).
781
+ #[no_mangle]
782
+ pub unsafe extern "C" fn castrum_session_seal_bytes(
783
+ id: *const u8,
784
+ id_len: usize,
785
+ data_json: *const u8,
786
+ data_len: usize,
787
+ exp_secs: i64,
788
+ secret: *const u8,
789
+ secret_len: usize,
790
+ ) -> *const std::os::raw::c_char {
791
+ if id.is_null() || data_json.is_null() || secret.is_null() {
792
+ return std::ptr::null();
793
+ }
794
+ let id_b = slice::from_raw_parts(id, id_len);
795
+ let data_b = slice::from_raw_parts(data_json, data_len);
796
+ let sec_b = slice::from_raw_parts(secret, secret_len);
797
+ let sealed = super::util::panic_guard(
798
+ || crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
799
+ None,
800
+ );
801
+ sealed_to_cstr(sealed)
802
+ }
803
+
804
+ /// Byte-arg sibling of `castrum_session_open`: `token`/`secret` cross as
805
+ /// `(ptr, len)`; the output layout and the needed-size convention are identical
806
+ /// to `castrum_session_open` (`0` = bad signature / malformed). Saves the
807
+ /// per-arg transcode and, unlike the `cstring` form, cannot be truncated by an
808
+ /// embedded `U+0000` in the token.
809
+ ///
810
+ /// # Safety
811
+ /// `token`/`secret` must be valid for reads of their stated lengths; `out` must
812
+ /// be valid for writes up to `out_cap`. Null pointers → 0.
813
+ #[no_mangle]
814
+ pub unsafe extern "C" fn castrum_session_open_bytes(
815
+ token: *const u8,
816
+ token_len: usize,
817
+ secret: *const u8,
818
+ secret_len: usize,
819
+ out: *mut u8,
820
+ out_cap: usize,
821
+ ) -> usize {
822
+ if token.is_null() || secret.is_null() || out.is_null() {
688
823
  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
824
  }
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
825
+ let tok = slice::from_raw_parts(token, token_len);
826
+ let sec = slice::from_raw_parts(secret, secret_len);
827
+ let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
828
+ pack_open_result(opened, out, out_cap)
708
829
  }
@@ -7,6 +7,8 @@
7
7
  use std::ptr;
8
8
  use std::slice;
9
9
 
10
+ use super::util::panic_guard;
11
+
10
12
  /// CRC32 over `data[0..len]`. Returns the CRC-32 checksum.
11
13
  ///
12
14
  /// # Safety
@@ -179,19 +181,22 @@ pub unsafe extern "C" fn castrum_json_sum_ids(
179
181
  if data.is_null() || out.is_null() {
180
182
  return 0;
181
183
  }
182
- match crate::json::json_ops::json_sum_ids_bytes(slice::from_raw_parts(data, len)) {
183
- Ok(sum) if out_len >= 9 => {
184
- *out = 1;
185
- ptr::copy_nonoverlapping(sum.to_le_bytes().as_ptr(), out.add(1), 8);
186
- 9
187
- }
188
- Ok(_) => 9, // too-small buffer → exact required size (growExact)
189
- Err(_) if out_len >= 1 => {
190
- *out = 0;
191
- 1
192
- }
193
- Err(_) => 1, // too-small buffer → exact required size
194
- }
184
+ panic_guard(
185
+ || match crate::json::json_ops::json_sum_ids_bytes(slice::from_raw_parts(data, len)) {
186
+ Ok(sum) if out_len >= 9 => {
187
+ *out = 1;
188
+ ptr::copy_nonoverlapping(sum.to_le_bytes().as_ptr(), out.add(1), 8);
189
+ 9
190
+ }
191
+ Ok(_) => 9, // too-small buffer → exact required size (growExact)
192
+ Err(_) if out_len >= 1 => {
193
+ *out = 0;
194
+ 1
195
+ }
196
+ Err(_) => 1, // too-small buffer → exact required size
197
+ },
198
+ 0,
199
+ )
195
200
  }
196
201
 
197
202
  /// Escape JS-RegExp metacharacters (`\ . * + ? ^ $ { } | ( ) [ ]`) in
@@ -212,13 +217,18 @@ pub unsafe extern "C" fn castrum_regex_escape(
212
217
  if data.is_null() || out.is_null() {
213
218
  return 0;
214
219
  }
215
- let input = slice::from_raw_parts(data, len);
216
- let needed = crate::util::text::regex_escape_len(input);
217
- if needed > out_cap {
218
- return needed;
219
- }
220
- crate::util::text::regex_escape_write(input, slice::from_raw_parts_mut(out, out_cap));
221
- needed
220
+ panic_guard(
221
+ || {
222
+ let input = slice::from_raw_parts(data, len);
223
+ let needed = crate::util::text::regex_escape_len(input);
224
+ if needed > out_cap {
225
+ return needed;
226
+ }
227
+ crate::util::text::regex_escape_write(input, slice::from_raw_parts_mut(out, out_cap));
228
+ needed
229
+ },
230
+ 0,
231
+ )
222
232
  }
223
233
 
224
234
  /// String-input / string-return sibling of [`castrum_regex_escape`]: the
@@ -269,17 +279,22 @@ pub unsafe extern "C" fn castrum_hex_validate_batch_str(
269
279
  if ids.is_null() || out.is_null() {
270
280
  return 0;
271
281
  }
272
- let input = std::ffi::CStr::from_ptr(ids).to_bytes();
273
- let needed = crate::util::validation::hex_batch_count(input);
274
- if needed > out_cap {
275
- return needed;
276
- }
277
- let mut buf = Vec::with_capacity(needed);
278
- match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
279
- Ok(()) if buf.len() <= out_cap => {
280
- slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
281
- buf.len()
282
- }
283
- _ => 0,
284
- }
282
+ panic_guard(
283
+ || {
284
+ let input = std::ffi::CStr::from_ptr(ids).to_bytes();
285
+ let needed = crate::util::validation::hex_batch_count(input);
286
+ if needed > out_cap {
287
+ return needed;
288
+ }
289
+ let mut buf = Vec::with_capacity(needed);
290
+ match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
291
+ Ok(()) if buf.len() <= out_cap => {
292
+ slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
293
+ buf.len()
294
+ }
295
+ _ => 0,
296
+ }
297
+ },
298
+ 0,
299
+ )
285
300
  }
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).