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.
- package/Cargo.lock +28 -44
- package/Cargo.toml +21 -4
- package/README.md +8 -6
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +4460 -3648
- package/docs/API.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CASE_STUDY.md +4 -2
- package/docs/ENVIRONMENT.md +9 -2
- package/docs/FFI_BUN_GUIDE.md +166 -9
- package/docs/HOW_TO_ADD_AN_OP.md +34 -0
- package/docs/REPO_MAP.md +16 -5
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +5 -0
- package/package.json +172 -163
- package/rust/crypto/pbkdf2.rs +9 -0
- package/rust/crypto/session.rs +164 -7
- package/rust/ffi/crypto.rs +119 -29
- package/rust/ffi/mod.rs +1 -0
- package/rust/ffi/task.rs +172 -0
- package/rust/ffi/tests.rs +173 -0
- package/rust/http/form.rs +6 -2
- package/rust/http/query_parser.rs +53 -22
- package/rust/ingress/native_route.rs +131 -140
- package/rust/ingress/pipeline.rs +3 -3
- package/rust/ingress/tests.rs +5 -4
- package/rust/json/json_ser.rs +13 -11
- package/rust/lib.rs +1 -0
- package/rust/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -0
- package/rust/util/bytes.rs +238 -51
- package/src/ingress/health.ts +1 -2
- package/src/native/ffi/build/codecs.ts +29 -7
- package/src/native/ffi/build/compress.ts +8 -0
- package/src/native/ffi/build/instances.ts +54 -2
- package/src/native/ffi/build/metrics.ts +23 -7
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +18 -0
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +3 -1
- package/src/native/ffi/types.ts +57 -0
- package/src/native/ffi.ts +85 -14
- package/src/native/loader.ts +53 -3
- package/src/rust-ffi/scalar/http.ts +54 -16
- package/src/rust-ffi/text.ts +25 -3
- package/src/shared/bytes.ts +19 -0
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- package/src/task/runtime.ts +568 -0
package/rust/ffi/crypto.rs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
package/rust/ffi/task.rs
ADDED
|
@@ -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
|
|
112
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 `=`),
|
|
63
|
-
/// malformed `%XX
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
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
|
|
173
|
-
// `%FF` decodes to a
|
|
174
|
-
//
|
|
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(),
|
|
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
|
}
|