castrum 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. package/src/task/runtime.ts +568 -0
@@ -625,6 +625,58 @@ pub unsafe extern "C" fn castrum_random_token_into(
625
625
 
626
626
  // ── Session envelope (fused JSON + HMAC) ────────────────────────────
627
627
 
628
+ /// Shared tail of both seal entry points: `None` (empty id/secret, or a panic)
629
+ /// → null pointer, otherwise clone the sealed token into the engine's
630
+ /// per-thread cstring buffer. Extracted so the cstring and byte forms cannot
631
+ /// diverge — the byte form exists only to change how the INPUTS cross.
632
+ fn sealed_to_cstr(sealed: Option<Vec<u8>>) -> *const std::os::raw::c_char {
633
+ let Some(tok) = sealed else {
634
+ return std::ptr::null();
635
+ };
636
+ super::util::cstring_return(tok.len(), move |buf| {
637
+ if buf.len() < tok.len() {
638
+ return None;
639
+ }
640
+ buf[..tok.len()].copy_from_slice(&tok);
641
+ Some(tok.len())
642
+ })
643
+ }
644
+
645
+ /// Shared tail of both open entry points: pack the extracted envelope into the
646
+ /// caller's buffer as `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`,
647
+ /// or report the EXACT size required when `out_cap` is too small. `None` (bad
648
+ /// signature / malformed envelope) → `0`. Never returns `0` on a valid call.
649
+ ///
650
+ /// # Safety
651
+ /// `out` must be valid for writes up to `out_cap`; the caller null-checks it.
652
+ unsafe fn pack_open_result(
653
+ opened: Option<(i64, Vec<u8>, Vec<u8>)>,
654
+ out: *mut u8,
655
+ out_cap: usize,
656
+ ) -> usize {
657
+ let Some((exp, id, data)) = opened else {
658
+ return 0;
659
+ };
660
+ // Layout size: 1 + 8 + 4+id + 4+data
661
+ let need = 1 + 8 + 4 + id.len() + 4 + data.len();
662
+ if need > out_cap {
663
+ return need;
664
+ }
665
+ let o = slice::from_raw_parts_mut(out, need);
666
+ o[0] = 1;
667
+ o[1..9].copy_from_slice(&exp.to_le_bytes());
668
+ let id_len = id.len() as u32;
669
+ o[9..13].copy_from_slice(&id_len.to_le_bytes());
670
+ let mut p = 13usize;
671
+ o[p..p + id.len()].copy_from_slice(&id);
672
+ p += id.len();
673
+ let d_len = data.len() as u32;
674
+ o[p..p + 4].copy_from_slice(&d_len.to_le_bytes());
675
+ p += 4;
676
+ o[p..p + data.len()].copy_from_slice(&data);
677
+ need
678
+ }
679
+
628
680
  /// Seal a session envelope NATIVELY: build
629
681
  /// `{"id":"…","data":<data_json>,"exp":exp}` and HMAC-sign it into the
630
682
  /// `payload.<64-hex>` cookie token — ONE crossing replaces the JS
@@ -650,16 +702,7 @@ pub unsafe extern "C" fn castrum_session_seal(
650
702
  || crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
651
703
  None,
652
704
  );
653
- let Some(tok) = sealed else {
654
- return std::ptr::null();
655
- };
656
- super::util::cstring_return(tok.len(), move |buf| {
657
- if buf.len() < tok.len() {
658
- return None;
659
- }
660
- buf[..tok.len()].copy_from_slice(&tok);
661
- Some(tok.len())
662
- })
705
+ sealed_to_cstr(sealed)
663
706
  }
664
707
 
665
708
  /// Open a sealed session token: verify the HMAC and extract the envelope in
@@ -684,25 +727,72 @@ pub unsafe extern "C" fn castrum_session_open(
684
727
  let tok = std::ffi::CStr::from_ptr(token).to_bytes();
685
728
  let sec = std::ffi::CStr::from_ptr(secret).to_bytes();
686
729
  let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
687
- let Some((exp, id, data)) = opened else {
730
+ pack_open_result(opened, out, out_cap)
731
+ }
732
+
733
+ /// Byte-arg sibling of `castrum_session_seal`: same core, same wire format, but
734
+ /// every input crosses as `(ptr, len)` instead of a `cstring` ARG.
735
+ ///
736
+ /// Per `docs/FFI_BUN_GUIDE.md` §6.1 there are two reasons, and only the second
737
+ /// cannot be worked around:
738
+ /// 1. a `cstring` ARG costs the engine a transcode into a call-scoped
739
+ /// NUL-terminated buffer (~80ns per arg through the public surface vs ~37ns
740
+ /// for the byte path);
741
+ /// 2. an embedded `U+0000` SILENTLY TRUNCATES the value before Rust sees it.
742
+ /// `id` and `data_json` are user-derived (the id is escaped by
743
+ /// `seal_core`, which only makes sense if it may contain arbitrary bytes),
744
+ /// so a session id carrying a NUL would be sealed under its truncated form.
745
+ /// This form carries the exact length and cannot be truncated.
746
+ ///
747
+ /// # Safety
748
+ /// `id`/`data_json`/`secret` must be valid for reads of their stated lengths;
749
+ /// null pointers are rejected (→ null).
750
+ #[no_mangle]
751
+ pub unsafe extern "C" fn castrum_session_seal_bytes(
752
+ id: *const u8,
753
+ id_len: usize,
754
+ data_json: *const u8,
755
+ data_len: usize,
756
+ exp_secs: i64,
757
+ secret: *const u8,
758
+ secret_len: usize,
759
+ ) -> *const std::os::raw::c_char {
760
+ if id.is_null() || data_json.is_null() || secret.is_null() {
761
+ return std::ptr::null();
762
+ }
763
+ let id_b = slice::from_raw_parts(id, id_len);
764
+ let data_b = slice::from_raw_parts(data_json, data_len);
765
+ let sec_b = slice::from_raw_parts(secret, secret_len);
766
+ let sealed = super::util::panic_guard(
767
+ || crate::crypto::session::seal_core(id_b, data_b, exp_secs, sec_b),
768
+ None,
769
+ );
770
+ sealed_to_cstr(sealed)
771
+ }
772
+
773
+ /// Byte-arg sibling of `castrum_session_open`: `token`/`secret` cross as
774
+ /// `(ptr, len)`; the output layout and the needed-size convention are identical
775
+ /// to `castrum_session_open` (`0` = bad signature / malformed). Saves the
776
+ /// per-arg transcode and, unlike the `cstring` form, cannot be truncated by an
777
+ /// embedded `U+0000` in the token.
778
+ ///
779
+ /// # Safety
780
+ /// `token`/`secret` must be valid for reads of their stated lengths; `out` must
781
+ /// be valid for writes up to `out_cap`. Null pointers → 0.
782
+ #[no_mangle]
783
+ pub unsafe extern "C" fn castrum_session_open_bytes(
784
+ token: *const u8,
785
+ token_len: usize,
786
+ secret: *const u8,
787
+ secret_len: usize,
788
+ out: *mut u8,
789
+ out_cap: usize,
790
+ ) -> usize {
791
+ if token.is_null() || secret.is_null() || out.is_null() {
688
792
  return 0;
689
- };
690
- // Layout size: 1 + 8 + 4+id + 4+data
691
- let need = 1 + 8 + 4 + id.len() + 4 + data.len();
692
- if need > out_cap {
693
- return need;
694
793
  }
695
- let o = slice::from_raw_parts_mut(out, need);
696
- o[0] = 1;
697
- o[1..9].copy_from_slice(&exp.to_le_bytes());
698
- let id_len = id.len() as u32;
699
- o[9..13].copy_from_slice(&id_len.to_le_bytes());
700
- let mut p = 13usize;
701
- o[p..p + id.len()].copy_from_slice(&id);
702
- p += id.len();
703
- let d_len = data.len() as u32;
704
- o[p..p + 4].copy_from_slice(&d_len.to_le_bytes());
705
- p += 4;
706
- o[p..p + data.len()].copy_from_slice(&data);
707
- need
794
+ let tok = slice::from_raw_parts(token, token_len);
795
+ let sec = slice::from_raw_parts(secret, secret_len);
796
+ let opened = panic_guard(|| crate::crypto::session::open_core(tok, sec), None);
797
+ pack_open_result(opened, out, out_cap)
708
798
  }
package/rust/ffi/mod.rs CHANGED
@@ -52,6 +52,7 @@ mod payload;
52
52
  mod probe;
53
53
  mod rate_limit;
54
54
  mod route;
55
+ mod task;
55
56
  mod util;
56
57
  mod validators;
57
58
 
@@ -0,0 +1,172 @@
1
+ // rust/ffi/task.rs — C-ABI exports for the off-thread task runtime.
2
+ //
3
+ // Eight symbols, all thin: the pool/ring/ops live in `rust/task/`. `submit`
4
+ // COPIES the packed args onto the worker (the JS caller's pooled buffer is
5
+ // released as soon as submit returns), and `drain` follows the repo-wide
6
+ // needed-size convention so JS grows once and retries once.
7
+
8
+ use std::slice;
9
+
10
+ use super::util::panic_guard;
11
+ use crate::task;
12
+
13
+ /// Start the pool with `threads` workers (0 → runtime default).
14
+ /// Returns `0` started, `1` already running, `2` internal error.
15
+ #[no_mangle]
16
+ pub extern "C" fn castrum_task_init(threads: u32) -> u32 {
17
+ panic_guard(|| task::init(Some(threads)), 2)
18
+ }
19
+
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).
22
+ ///
23
+ /// # Safety
24
+ /// `args` must be valid for reads of `args_len` bytes.
25
+ #[no_mangle]
26
+ pub unsafe extern "C" fn castrum_task_submit(
27
+ op: u32,
28
+ args: *const u8,
29
+ args_len: usize,
30
+ task_id: usize,
31
+ ) -> u32 {
32
+ if args.is_null() && args_len != 0 {
33
+ return 0;
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))
41
+ }
42
+
43
+ /// Drain finished tasks into `out` (packed `[u32 count][entry…]`).
44
+ /// Returns bytes written, `0` when nothing is pending, or the EXACT required
45
+ /// size when `out` is too small (nothing is consumed in that case).
46
+ ///
47
+ /// # Safety
48
+ /// `out` must be valid for writes of `out_cap` bytes.
49
+ #[no_mangle]
50
+ pub unsafe extern "C" fn castrum_task_drain(out: *mut u8, out_cap: usize) -> usize {
51
+ if out.is_null() {
52
+ return 0;
53
+ }
54
+ panic_guard(|| task::drain(slice::from_raw_parts_mut(out, out_cap)), 0)
55
+ }
56
+
57
+ /// Completions waiting to be drained.
58
+ #[no_mangle]
59
+ pub extern "C" fn castrum_task_pending() -> u32 {
60
+ task::pending()
61
+ }
62
+
63
+ /// Submit an op that writes its result DIRECTLY into `out` (zero-copy output):
64
+ /// the completion body is the 8-byte LE written length, not the data, so JS
65
+ /// resolves a view of the buffer it already owns. A too-small `out` completes
66
+ /// with status 3 (TOO_SMALL) and the exact required size as the body; status 4
67
+ /// (UNSUPPORTED) means the op has no `_into` form and the caller should fall
68
+ /// back to `castrum_task_submit`.
69
+ ///
70
+ /// Returns `1` accepted, `0` rejected.
71
+ ///
72
+ /// # Safety
73
+ /// `args` must be valid for reads of `args_len` bytes. `out` must be valid for
74
+ /// writes of `out_cap` bytes and must stay alive (and untouched by JS) until the
75
+ /// completion for `task_id` is drained.
76
+ #[no_mangle]
77
+ pub unsafe extern "C" fn castrum_task_submit_out(
78
+ op: u32,
79
+ args: *const u8,
80
+ args_len: usize,
81
+ task_id: usize,
82
+ out: *mut u8,
83
+ out_cap: usize,
84
+ ) -> u32 {
85
+ if (args.is_null() && args_len != 0) || out.is_null() || out_cap == 0 {
86
+ return 0;
87
+ }
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
+ ))
100
+ }
101
+
102
+ /// Mark `task_id` cancelled. Returns `1` when newly marked, `0` otherwise.
103
+ #[no_mangle]
104
+ pub extern "C" fn castrum_task_cancel(task_id: usize) -> u32 {
105
+ u32::from(task::mark_cancelled(task_id as u64))
106
+ }
107
+
108
+ /// Submit an op whose PAYLOAD is read IN PLACE from the caller's buffer
109
+ /// (zero-copy INPUT). Only the `hdr_len` header bytes are copied, so a large
110
+ /// argument never crosses the boundary — without this, offloading a big
111
+ /// compression costs a full-size copy on the JS thread and measured WORSE than
112
+ /// the synchronous built-in (docs/RND-CONCURRENCY.md §7b).
113
+ ///
114
+ /// Returns `1` accepted, `0` rejected.
115
+ ///
116
+ /// # Safety
117
+ /// `hdr` must be valid for reads of `hdr_len` bytes. `data` must be valid for
118
+ /// reads of `data_len` bytes and must stay alive (and unmodified by JS) until
119
+ /// the completion for `task_id` is drained.
120
+ #[no_mangle]
121
+ pub unsafe extern "C" fn castrum_task_submit_slice(
122
+ op: u32,
123
+ hdr: *const u8,
124
+ hdr_len: usize,
125
+ data: *const u8,
126
+ data_len: usize,
127
+ task_id: usize,
128
+ ) -> u32 {
129
+ if (hdr.is_null() && hdr_len != 0) || (data.is_null() && data_len != 0) {
130
+ return 0;
131
+ }
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
+ ))
146
+ }
147
+
148
+ /// Register the thread-safe doorbell trampoline address (`0` disables it).
149
+ /// Returns `1` on success.
150
+ ///
151
+ /// # Safety
152
+ /// `cb` must be a live `void()` C trampoline that is safe to call from any
153
+ /// thread (a `bun:ffi` `JSCallback({ threadsafe: true }).ptr`).
154
+ #[no_mangle]
155
+ pub extern "C" fn castrum_task_set_doorbell(cb: *const std::os::raw::c_void) -> u32 {
156
+ task::set_doorbell(cb as usize);
157
+ 1
158
+ }
159
+
160
+ /// Stop the pool and drop pending completions. Returns `0`.
161
+ #[no_mangle]
162
+ pub extern "C" fn castrum_task_shutdown() -> u32 {
163
+ task::shutdown();
164
+ task::completion::clear();
165
+ 0
166
+ }
167
+
168
+ /// Configured pool worker count (0 before the pool starts).
169
+ #[no_mangle]
170
+ pub extern "C" fn castrum_task_threads() -> u32 {
171
+ task::threads()
172
+ }
package/rust/ffi/tests.rs CHANGED
@@ -2308,6 +2308,179 @@ fn wire_validate_and_session_c_abi() {
2308
2308
  unsafe { castrum_session_open(token, c"wrong".as_ptr(), buf.as_mut_ptr(), buf.len()) },
2309
2309
  0
2310
2310
  );
2311
+
2312
+ // ── Byte-arg siblings ──────────────────────────────────────────
2313
+ // Copy the first token OUT of the shared per-thread CSTR_BUF before the
2314
+ // next `cstring` return reuses it (that buffer is one-per-thread by design).
2315
+ let token_str = unsafe { std::ffi::CStr::from_ptr(token) }
2316
+ .to_string_lossy()
2317
+ .into_owned();
2318
+ let id = b"sess-9";
2319
+ let data = b"{\"n\":1}";
2320
+ let secret = b"sekrit";
2321
+ let token_b = unsafe {
2322
+ castrum_session_seal_bytes(
2323
+ id.as_ptr(),
2324
+ id.len(),
2325
+ data.as_ptr(),
2326
+ data.len(),
2327
+ 1_234_567,
2328
+ secret.as_ptr(),
2329
+ secret.len(),
2330
+ )
2331
+ };
2332
+ assert!(!token_b.is_null());
2333
+ // One shared core ⇒ byte-identical token. This is the parity guarantee that
2334
+ // makes the byte form a drop-in for the cstring form.
2335
+ let token_b_str = unsafe { std::ffi::CStr::from_ptr(token_b) }
2336
+ .to_string_lossy()
2337
+ .into_owned();
2338
+ assert_eq!(token_b_str, token_str);
2339
+
2340
+ let tok_bytes = token_b_str.as_bytes();
2341
+ let mut buf2 = [0u8; 256];
2342
+ let w2 = unsafe {
2343
+ castrum_session_open_bytes(
2344
+ tok_bytes.as_ptr(),
2345
+ tok_bytes.len(),
2346
+ secret.as_ptr(),
2347
+ secret.len(),
2348
+ buf2.as_mut_ptr(),
2349
+ buf2.len(),
2350
+ )
2351
+ };
2352
+ assert_eq!(w2, w);
2353
+ assert_eq!(buf2[..w2], buf[..w]);
2354
+ // bad signature → 0
2355
+ assert_eq!(
2356
+ unsafe {
2357
+ castrum_session_open_bytes(
2358
+ tok_bytes.as_ptr(),
2359
+ tok_bytes.len(),
2360
+ b"wrong".as_ptr(),
2361
+ 5,
2362
+ buf2.as_mut_ptr(),
2363
+ buf2.len(),
2364
+ )
2365
+ },
2366
+ 0
2367
+ );
2368
+ // Too-small buffer → the EXACT required size, not 0 (needed-size rule).
2369
+ assert_eq!(
2370
+ unsafe {
2371
+ castrum_session_open_bytes(
2372
+ tok_bytes.as_ptr(),
2373
+ tok_bytes.len(),
2374
+ secret.as_ptr(),
2375
+ secret.len(),
2376
+ buf2.as_mut_ptr(),
2377
+ 4,
2378
+ )
2379
+ },
2380
+ w2
2381
+ );
2382
+
2383
+ // The reason this sibling exists: the cstring form TRUNCATES at an embedded
2384
+ // NUL (the engine's transcode is NUL-terminated), so an id carrying one is
2385
+ // sealed under its truncated form. The byte form carries the exact length.
2386
+ //
2387
+ // Each token is copied to a String IMMEDIATELY after its own call: both
2388
+ // `cstring` returns write into ONE per-thread CSTR_BUF, so the earlier
2389
+ // pointer is dead the moment the next call reuses it — the aliasing hazard
2390
+ // `docs/FFI_BUN_GUIDE.md` documents. The assertions below also read the
2391
+ // recovered `id` BYTES rather than the token's text, so they hold
2392
+ // regardless of how the payload is wire-encoded.
2393
+ let nul_id = b"sess-9\0extra";
2394
+ let tp = unsafe {
2395
+ castrum_session_seal(
2396
+ nul_id.as_ptr() as *const std::os::raw::c_char,
2397
+ c"{\"n\":1}".as_ptr(),
2398
+ 1_234_567,
2399
+ c"sekrit".as_ptr(),
2400
+ )
2401
+ };
2402
+ assert!(!tp.is_null());
2403
+ let truncated_str = unsafe { std::ffi::CStr::from_ptr(tp) }
2404
+ .to_string_lossy()
2405
+ .into_owned();
2406
+
2407
+ let ep = unsafe {
2408
+ castrum_session_seal_bytes(
2409
+ nul_id.as_ptr(),
2410
+ nul_id.len(),
2411
+ data.as_ptr(),
2412
+ data.len(),
2413
+ 1_234_567,
2414
+ secret.as_ptr(),
2415
+ secret.len(),
2416
+ )
2417
+ };
2418
+ assert!(!ep.is_null());
2419
+ let exact_str = unsafe { std::ffi::CStr::from_ptr(ep) }
2420
+ .to_string_lossy()
2421
+ .into_owned();
2422
+
2423
+ assert_ne!(
2424
+ exact_str, truncated_str,
2425
+ "byte form must not truncate at NUL"
2426
+ );
2427
+
2428
+ // Open each token and read back the `id` from the packed layout
2429
+ // (`[u8 ok][i64 exp][u32 idLen][id]…`) — the ground truth this change is
2430
+ // about. NOTE: `seal_core` JSON-escapes the id but `open_core` returns the
2431
+ // raw captured span, so an id needing escapes comes back escaped. That
2432
+ // asymmetry is pre-existing; these assertions are written not to depend on
2433
+ // it, only on the truncation itself.
2434
+ let id_of = |tok: &str, out: &mut [u8; 256]| -> Vec<u8> {
2435
+ let w = unsafe {
2436
+ castrum_session_open_bytes(
2437
+ tok.as_ptr(),
2438
+ tok.len(),
2439
+ secret.as_ptr(),
2440
+ secret.len(),
2441
+ out.as_mut_ptr(),
2442
+ out.len(),
2443
+ )
2444
+ };
2445
+ assert!(w > 13, "open of {tok:?} failed");
2446
+ let id_len = u32::from_le_bytes([out[9], out[10], out[11], out[12]]) as usize;
2447
+ out[13..13 + id_len].to_vec()
2448
+ };
2449
+ let mut scratch = [0u8; 256];
2450
+ let truncated_id = id_of(&truncated_str, &mut scratch);
2451
+ let exact_id = id_of(&exact_str, &mut scratch);
2452
+ assert_eq!(truncated_id, b"sess-9");
2453
+ // The byte form carries the exact length, so the id comes back WHOLE — and
2454
+ // byte-exact, because `open_core` now decodes the id rather than returning
2455
+ // the escaped span.
2456
+ assert_eq!(exact_id, &nul_id[..]);
2457
+
2458
+ // Null pointers are rejected, never dereferenced.
2459
+ assert!(unsafe {
2460
+ castrum_session_seal_bytes(
2461
+ std::ptr::null(),
2462
+ 0,
2463
+ std::ptr::null(),
2464
+ 0,
2465
+ 0,
2466
+ std::ptr::null(),
2467
+ 0,
2468
+ )
2469
+ }
2470
+ .is_null());
2471
+ assert_eq!(
2472
+ unsafe {
2473
+ castrum_session_open_bytes(
2474
+ std::ptr::null(),
2475
+ 0,
2476
+ secret.as_ptr(),
2477
+ secret.len(),
2478
+ buf2.as_mut_ptr(),
2479
+ buf2.len(),
2480
+ )
2481
+ },
2482
+ 0
2483
+ );
2311
2484
  }
2312
2485
 
2313
2486
  #[test]
package/rust/http/form.rs CHANGED
@@ -108,8 +108,12 @@ mod tests {
108
108
  }
109
109
 
110
110
  #[test]
111
- fn form_parse_rejects_malformed_percent() {
112
- assert!(form_parse_packed_vec(b"a=%ZZ").is_err());
111
+ fn form_parse_malformed_percent_falls_back_to_raw() {
112
+ let packed = form_parse_packed_vec(b"a=%ZZ").unwrap();
113
+ assert_eq!(
114
+ crate::test_support::decode_packed_pairs(&packed),
115
+ vec![(b"a".to_vec(), b"%ZZ".to_vec())]
116
+ );
113
117
  }
114
118
 
115
119
  #[test]
@@ -15,16 +15,14 @@ fn write_decoded_form_component(src: &[u8], out: &mut [u8], pos: &mut usize) ->
15
15
  // the remaining buffer against the ACTUAL decoded length (so a `%XX`-heavy
16
16
  // component only needs room for its decoded form, matching the pre-refactor
17
17
  // behavior where a buffer sized to the decoded length succeeds).
18
- let written = crate::util::bytes::decode_form_component_into(src, &mut out[*pos..]).map_err(
19
- |e| match e {
20
- crate::util::bytes::FormDecodeError::Malformed => {
21
- Error::from_reason("invalid %-encoding: malformed %XX sequence")
22
- }
23
- crate::util::bytes::FormDecodeError::BufferTooSmall => {
24
- Error::from_reason("packed output: buffer too small")
25
- }
26
- },
27
- )?;
18
+ //
19
+ // A malformed escape is NOT an error here: the shared decoder answers with
20
+ // the raw component, exactly like the pure-TS fallback
21
+ // (`decodeURIComponent` throws → `catch` returns the segment). The only
22
+ // error left is a too-small output buffer, which the C-ABI caller turns
23
+ // into the needed-size answer.
24
+ let written = crate::util::bytes::decode_form_component_into(src, &mut out[*pos..])
25
+ .map_err(|_| Error::from_reason("packed output: buffer too small"))?;
28
26
  *pos += written;
29
27
  let decoded_len = written as u32;
30
28
  out[len_pos..len_pos + 4].copy_from_slice(&decoded_len.to_le_bytes());
@@ -59,8 +57,9 @@ pub fn query_parse_packed_into_slice(input: &[u8], out: &mut [u8]) -> Result<usi
59
57
  /// Compute the EXACT packed output size for `input` WITHOUT writing — the
60
58
  /// "needed-size" pass for the C-ABI convention. Mirrors
61
59
  /// [`query_parse_packed_into_slice`]'s structure exactly (split on `&`, skip
62
- /// empty pairs, split at `=`), so the reported size is byte-exact and a
63
- /// malformed `%XX` still surfaces as `Err` (caller → `0`).
60
+ /// empty pairs, split at `=`) AND its decoding, including the raw fallback for a
61
+ /// malformed `%XX`: the reported size and the written size must never disagree,
62
+ /// or the caller's grow-and-retry loop would never terminate.
64
63
  #[inline]
65
64
  pub fn query_parse_packed_size(input: &[u8]) -> Result<usize> {
66
65
  let mut size = 4usize; // count prefix
@@ -72,10 +71,8 @@ pub fn query_parse_packed_size(input: &[u8]) -> Result<usize> {
72
71
  Some(eq) => (&pair[..eq], &pair[eq + 1..]),
73
72
  None => (pair, &[][..]),
74
73
  };
75
- size += 4 + crate::util::bytes::decode_form_component_len(key)
76
- .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
77
- size += 4 + crate::util::bytes::decode_form_component_len(value)
78
- .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
74
+ size += 4 + crate::util::bytes::decode_form_component_len(key);
75
+ size += 4 + crate::util::bytes::decode_form_component_len(value);
79
76
  }
80
77
  Ok(size)
81
78
  }
@@ -139,8 +136,34 @@ mod tests {
139
136
  }
140
137
 
141
138
  #[test]
142
- fn query_parse_invalid_percent_rejected() {
143
- assert!(query_parse_packed_vec(b"a=%ZZ").is_err());
139
+ fn query_parse_malformed_percent_falls_back_to_raw() {
140
+ // JS: `try { decodeURIComponent(s.replace(/\+/g," ")) } catch { return s }`
141
+ // — applied PER COMPONENT, so name and value fail independently. Checked
142
+ // against Bun's `decodeURIComponent`:
143
+ // "a=%ZZ" → [["a", "%ZZ"]]
144
+ // "a+b=%2" → [["a b", "%2"]] (`+` in the NAME still decodes)
145
+ // "x=1+2%ZZ" → [["x", "1+2%ZZ"]] (raw keeps its `+`)
146
+ // "a+b=%20" → [["a b", " "]]
147
+ let packed = query_parse_packed_vec(b"a=%ZZ").unwrap();
148
+ assert_eq!(
149
+ decode_packed_pairs(&packed),
150
+ vec![(b"a".to_vec(), b"%ZZ".to_vec())]
151
+ );
152
+ let packed = query_parse_packed_vec(b"a+b=%2").unwrap();
153
+ assert_eq!(
154
+ decode_packed_pairs(&packed),
155
+ vec![(b"a b".to_vec(), b"%2".to_vec())]
156
+ );
157
+ let packed = query_parse_packed_vec(b"x=1+2%ZZ").unwrap();
158
+ assert_eq!(
159
+ decode_packed_pairs(&packed),
160
+ vec![(b"x".to_vec(), b"1+2%ZZ".to_vec())]
161
+ );
162
+ let packed = query_parse_packed_vec(b"a+b=%20").unwrap();
163
+ assert_eq!(
164
+ decode_packed_pairs(&packed),
165
+ vec![(b"a b".to_vec(), b" ".to_vec())]
166
+ );
144
167
  }
145
168
 
146
169
  #[test]
@@ -169,12 +192,20 @@ mod tests {
169
192
  }
170
193
 
171
194
  #[test]
172
- fn query_parse_non_utf8_byte_passthrough() {
173
- // `%FF` decodes to a raw 0xFF byte; the parser does not require valid
174
- // UTF-8 in query values (callers must handle it when they do).
195
+ fn query_parse_non_utf8_escape_falls_back_to_raw() {
196
+ // `%FF` decodes to a byte that cannot appear in valid UTF-8, so JS's
197
+ // `decodeURIComponent` throws and the component is returned raw. The
198
+ // packed output must therefore hold the literal `%FF` text, NOT a 0xFF
199
+ // byte (which a JS caller could only read back lossily as U+FFFD).
175
200
  let packed = query_parse_packed_vec(b"a=%FF").unwrap();
176
201
  let pairs = decode_packed_pairs(&packed);
177
202
  assert_eq!(pairs.len(), 1);
178
- assert_eq!(pairs[0], (b"a".to_vec(), vec![0xFF]));
203
+ assert_eq!(pairs[0], (b"a".to_vec(), b"%FF".to_vec()));
204
+ // A literal invalid byte with nothing to decode still passes through.
205
+ let packed = query_parse_packed_vec(b"a=\xFF").unwrap();
206
+ assert_eq!(
207
+ decode_packed_pairs(&packed),
208
+ vec![(b"a".to_vec(), b"\xFF".to_vec())]
209
+ );
179
210
  }
180
211
  }