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.
- package/Cargo.lock +4 -3
- package/Cargo.toml +1 -1
- 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 +501 -5
- package/dist/index.js +1658 -1314
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +13 -1
- package/docs/HOW_TO_ADD_AN_OP.md +24 -0
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +8 -6
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +170 -7
- package/rust/ffi/crypto.rs +182 -61
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +51 -32
- package/rust/ffi/tests.rs +197 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +39 -241
- package/src/native/ffi/build/metrics.ts +0 -7
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +17 -0
- package/src/native/ffi.ts +2 -422
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
package/rust/ffi/payload.rs
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// Attacker-controlled frame bytes: a panic must become 0.
|
|
119
|
+
panic_guard(
|
|
120
|
+
|| {
|
|
121
|
+
let Some(frame) =
|
|
122
|
+
crate::payload::ws_frames::decode_frame(slice::from_raw_parts(data, len))
|
|
123
|
+
else {
|
|
124
|
+
return 0;
|
|
125
|
+
};
|
|
126
|
+
let Some(need) = 6usize.checked_add(frame.payload.len()) else {
|
|
127
|
+
return 0;
|
|
128
|
+
};
|
|
129
|
+
if need > out_cap {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
let o = slice::from_raw_parts_mut(out, need);
|
|
133
|
+
o[0] = u8::from(frame.fin);
|
|
134
|
+
o[1] = frame.opcode;
|
|
135
|
+
o[2..6].copy_from_slice(&(frame.payload.len() as u32).to_le_bytes());
|
|
136
|
+
o[6..].copy_from_slice(&frame.payload);
|
|
137
|
+
need
|
|
138
|
+
},
|
|
139
|
+
0,
|
|
140
|
+
)
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
// ── Compression (gzip / brotli) ──────────────────────────────────
|
package/rust/ffi/task.rs
CHANGED
|
@@ -32,12 +32,17 @@ pub unsafe extern "C" fn castrum_task_submit(
|
|
|
32
32
|
if args.is_null() && args_len != 0 {
|
|
33
33
|
return 0;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
panic_guard(
|
|
36
|
+
|| {
|
|
37
|
+
let owned: Vec<u8> = if args_len == 0 {
|
|
38
|
+
Vec::new()
|
|
39
|
+
} else {
|
|
40
|
+
slice::from_raw_parts(args, args_len).to_vec()
|
|
41
|
+
};
|
|
42
|
+
u32::from(task::submit_op(op, owned, task_id as u64))
|
|
43
|
+
},
|
|
44
|
+
0,
|
|
45
|
+
)
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/// Drain finished tasks into `out` (packed `[u32 count][entry…]`).
|
|
@@ -85,18 +90,23 @@ pub unsafe extern "C" fn castrum_task_submit_out(
|
|
|
85
90
|
if (args.is_null() && args_len != 0) || out.is_null() || out_cap == 0 {
|
|
86
91
|
return 0;
|
|
87
92
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
panic_guard(
|
|
94
|
+
|| {
|
|
95
|
+
let owned: Vec<u8> = if args_len == 0 {
|
|
96
|
+
Vec::new()
|
|
97
|
+
} else {
|
|
98
|
+
slice::from_raw_parts(args, args_len).to_vec()
|
|
99
|
+
};
|
|
100
|
+
u32::from(task::submit_op_into(
|
|
101
|
+
op,
|
|
102
|
+
owned,
|
|
103
|
+
task_id as u64,
|
|
104
|
+
out as usize,
|
|
105
|
+
out_cap,
|
|
106
|
+
))
|
|
107
|
+
},
|
|
108
|
+
0,
|
|
109
|
+
)
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
/// Mark `task_id` cancelled. Returns `1` when newly marked, `0` otherwise.
|
|
@@ -129,20 +139,25 @@ pub unsafe extern "C" fn castrum_task_submit_slice(
|
|
|
129
139
|
if (hdr.is_null() && hdr_len != 0) || (data.is_null() && data_len != 0) {
|
|
130
140
|
return 0;
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
panic_guard(
|
|
143
|
+
|| {
|
|
144
|
+
// The header is small, so copying it keeps the caller free to reuse a
|
|
145
|
+
// scratch buffer; the payload is never copied.
|
|
146
|
+
let owned = if hdr_len == 0 {
|
|
147
|
+
Vec::new()
|
|
148
|
+
} else {
|
|
149
|
+
slice::from_raw_parts(hdr, hdr_len).to_vec()
|
|
150
|
+
};
|
|
151
|
+
u32::from(task::submit_slice_op(
|
|
152
|
+
op,
|
|
153
|
+
owned,
|
|
154
|
+
data as usize,
|
|
155
|
+
data_len,
|
|
156
|
+
task_id as u64,
|
|
157
|
+
))
|
|
158
|
+
},
|
|
159
|
+
0,
|
|
160
|
+
)
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
/// Register the thread-safe doorbell trampoline address (`0` disables it).
|
|
@@ -158,8 +173,12 @@ pub extern "C" fn castrum_task_set_doorbell(cb: *const std::os::raw::c_void) ->
|
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
/// Stop the pool and drop pending completions. Returns `0`.
|
|
176
|
+
///
|
|
177
|
+
/// The doorbell trampoline is cleared FIRST: a worker completing after this
|
|
178
|
+
/// point must not call a JS callback the owner is about to close.
|
|
161
179
|
#[no_mangle]
|
|
162
180
|
pub extern "C" fn castrum_task_shutdown() -> u32 {
|
|
181
|
+
task::completion::clear_doorbell();
|
|
163
182
|
task::shutdown();
|
|
164
183
|
task::completion::clear();
|
|
165
184
|
0
|
package/rust/ffi/tests.rs
CHANGED
|
@@ -189,6 +189,30 @@ fn utf8_valid_c_abi() {
|
|
|
189
189
|
assert_eq!(unsafe { castrum_utf8_valid(b"".as_ptr(), 0) }, 1);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
#[test]
|
|
193
|
+
fn borrowed_hmac_key_preserves_signatures_after_eviction() {
|
|
194
|
+
HMAC_KEY_CACHE.with(|c| c.borrow_mut().clear());
|
|
195
|
+
for i in 0..(HMAC_KEY_CACHE_CAP * 3) {
|
|
196
|
+
let secret = format!("borrowed-key-{}", i % (HMAC_KEY_CACHE_CAP + 1));
|
|
197
|
+
let expected = hmac::sign(&hmac_key(secret.as_bytes()), b"payload");
|
|
198
|
+
for _ in 0..2 {
|
|
199
|
+
let actual = with_hmac_key_cached(secret.as_bytes(), |key| hmac::sign(key, b"payload"));
|
|
200
|
+
assert_eq!(actual.as_ref(), expected.as_ref());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
fn borrowed_hmac_key_releases_cache_on_unwind() {
|
|
207
|
+
let caught = std::panic::catch_unwind(|| {
|
|
208
|
+
with_hmac_key_cached(b"borrow-panic", |_| panic!("test callback panic"));
|
|
209
|
+
});
|
|
210
|
+
assert!(caught.is_err());
|
|
211
|
+
let actual = with_hmac_key_cached(b"borrow-panic", |key| hmac::sign(key, b"payload"));
|
|
212
|
+
let expected = hmac::sign(&hmac_key(b"borrow-panic"), b"payload");
|
|
213
|
+
assert_eq!(actual.as_ref(), expected.as_ref());
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
#[test]
|
|
193
217
|
fn hmac_key_cache_reuses_compiled_key() {
|
|
194
218
|
// Same-secret calls must hit the cache (the whole point of the LRU):
|
|
@@ -2308,6 +2332,179 @@ fn wire_validate_and_session_c_abi() {
|
|
|
2308
2332
|
unsafe { castrum_session_open(token, c"wrong".as_ptr(), buf.as_mut_ptr(), buf.len()) },
|
|
2309
2333
|
0
|
|
2310
2334
|
);
|
|
2335
|
+
|
|
2336
|
+
// ── Byte-arg siblings ──────────────────────────────────────────
|
|
2337
|
+
// Copy the first token OUT of the shared per-thread CSTR_BUF before the
|
|
2338
|
+
// next `cstring` return reuses it (that buffer is one-per-thread by design).
|
|
2339
|
+
let token_str = unsafe { std::ffi::CStr::from_ptr(token) }
|
|
2340
|
+
.to_string_lossy()
|
|
2341
|
+
.into_owned();
|
|
2342
|
+
let id = b"sess-9";
|
|
2343
|
+
let data = b"{\"n\":1}";
|
|
2344
|
+
let secret = b"sekrit";
|
|
2345
|
+
let token_b = unsafe {
|
|
2346
|
+
castrum_session_seal_bytes(
|
|
2347
|
+
id.as_ptr(),
|
|
2348
|
+
id.len(),
|
|
2349
|
+
data.as_ptr(),
|
|
2350
|
+
data.len(),
|
|
2351
|
+
1_234_567,
|
|
2352
|
+
secret.as_ptr(),
|
|
2353
|
+
secret.len(),
|
|
2354
|
+
)
|
|
2355
|
+
};
|
|
2356
|
+
assert!(!token_b.is_null());
|
|
2357
|
+
// One shared core ⇒ byte-identical token. This is the parity guarantee that
|
|
2358
|
+
// makes the byte form a drop-in for the cstring form.
|
|
2359
|
+
let token_b_str = unsafe { std::ffi::CStr::from_ptr(token_b) }
|
|
2360
|
+
.to_string_lossy()
|
|
2361
|
+
.into_owned();
|
|
2362
|
+
assert_eq!(token_b_str, token_str);
|
|
2363
|
+
|
|
2364
|
+
let tok_bytes = token_b_str.as_bytes();
|
|
2365
|
+
let mut buf2 = [0u8; 256];
|
|
2366
|
+
let w2 = unsafe {
|
|
2367
|
+
castrum_session_open_bytes(
|
|
2368
|
+
tok_bytes.as_ptr(),
|
|
2369
|
+
tok_bytes.len(),
|
|
2370
|
+
secret.as_ptr(),
|
|
2371
|
+
secret.len(),
|
|
2372
|
+
buf2.as_mut_ptr(),
|
|
2373
|
+
buf2.len(),
|
|
2374
|
+
)
|
|
2375
|
+
};
|
|
2376
|
+
assert_eq!(w2, w);
|
|
2377
|
+
assert_eq!(buf2[..w2], buf[..w]);
|
|
2378
|
+
// bad signature → 0
|
|
2379
|
+
assert_eq!(
|
|
2380
|
+
unsafe {
|
|
2381
|
+
castrum_session_open_bytes(
|
|
2382
|
+
tok_bytes.as_ptr(),
|
|
2383
|
+
tok_bytes.len(),
|
|
2384
|
+
b"wrong".as_ptr(),
|
|
2385
|
+
5,
|
|
2386
|
+
buf2.as_mut_ptr(),
|
|
2387
|
+
buf2.len(),
|
|
2388
|
+
)
|
|
2389
|
+
},
|
|
2390
|
+
0
|
|
2391
|
+
);
|
|
2392
|
+
// Too-small buffer → the EXACT required size, not 0 (needed-size rule).
|
|
2393
|
+
assert_eq!(
|
|
2394
|
+
unsafe {
|
|
2395
|
+
castrum_session_open_bytes(
|
|
2396
|
+
tok_bytes.as_ptr(),
|
|
2397
|
+
tok_bytes.len(),
|
|
2398
|
+
secret.as_ptr(),
|
|
2399
|
+
secret.len(),
|
|
2400
|
+
buf2.as_mut_ptr(),
|
|
2401
|
+
4,
|
|
2402
|
+
)
|
|
2403
|
+
},
|
|
2404
|
+
w2
|
|
2405
|
+
);
|
|
2406
|
+
|
|
2407
|
+
// The reason this sibling exists: the cstring form TRUNCATES at an embedded
|
|
2408
|
+
// NUL (the engine's transcode is NUL-terminated), so an id carrying one is
|
|
2409
|
+
// sealed under its truncated form. The byte form carries the exact length.
|
|
2410
|
+
//
|
|
2411
|
+
// Each token is copied to a String IMMEDIATELY after its own call: both
|
|
2412
|
+
// `cstring` returns write into ONE per-thread CSTR_BUF, so the earlier
|
|
2413
|
+
// pointer is dead the moment the next call reuses it — the aliasing hazard
|
|
2414
|
+
// `docs/FFI_BUN_GUIDE.md` documents. The assertions below also read the
|
|
2415
|
+
// recovered `id` BYTES rather than the token's text, so they hold
|
|
2416
|
+
// regardless of how the payload is wire-encoded.
|
|
2417
|
+
let nul_id = b"sess-9\0extra";
|
|
2418
|
+
let tp = unsafe {
|
|
2419
|
+
castrum_session_seal(
|
|
2420
|
+
nul_id.as_ptr() as *const std::os::raw::c_char,
|
|
2421
|
+
c"{\"n\":1}".as_ptr(),
|
|
2422
|
+
1_234_567,
|
|
2423
|
+
c"sekrit".as_ptr(),
|
|
2424
|
+
)
|
|
2425
|
+
};
|
|
2426
|
+
assert!(!tp.is_null());
|
|
2427
|
+
let truncated_str = unsafe { std::ffi::CStr::from_ptr(tp) }
|
|
2428
|
+
.to_string_lossy()
|
|
2429
|
+
.into_owned();
|
|
2430
|
+
|
|
2431
|
+
let ep = unsafe {
|
|
2432
|
+
castrum_session_seal_bytes(
|
|
2433
|
+
nul_id.as_ptr(),
|
|
2434
|
+
nul_id.len(),
|
|
2435
|
+
data.as_ptr(),
|
|
2436
|
+
data.len(),
|
|
2437
|
+
1_234_567,
|
|
2438
|
+
secret.as_ptr(),
|
|
2439
|
+
secret.len(),
|
|
2440
|
+
)
|
|
2441
|
+
};
|
|
2442
|
+
assert!(!ep.is_null());
|
|
2443
|
+
let exact_str = unsafe { std::ffi::CStr::from_ptr(ep) }
|
|
2444
|
+
.to_string_lossy()
|
|
2445
|
+
.into_owned();
|
|
2446
|
+
|
|
2447
|
+
assert_ne!(
|
|
2448
|
+
exact_str, truncated_str,
|
|
2449
|
+
"byte form must not truncate at NUL"
|
|
2450
|
+
);
|
|
2451
|
+
|
|
2452
|
+
// Open each token and read back the `id` from the packed layout
|
|
2453
|
+
// (`[u8 ok][i64 exp][u32 idLen][id]…`) — the ground truth this change is
|
|
2454
|
+
// about. NOTE: `seal_core` JSON-escapes the id but `open_core` returns the
|
|
2455
|
+
// raw captured span, so an id needing escapes comes back escaped. That
|
|
2456
|
+
// asymmetry is pre-existing; these assertions are written not to depend on
|
|
2457
|
+
// it, only on the truncation itself.
|
|
2458
|
+
let id_of = |tok: &str, out: &mut [u8; 256]| -> Vec<u8> {
|
|
2459
|
+
let w = unsafe {
|
|
2460
|
+
castrum_session_open_bytes(
|
|
2461
|
+
tok.as_ptr(),
|
|
2462
|
+
tok.len(),
|
|
2463
|
+
secret.as_ptr(),
|
|
2464
|
+
secret.len(),
|
|
2465
|
+
out.as_mut_ptr(),
|
|
2466
|
+
out.len(),
|
|
2467
|
+
)
|
|
2468
|
+
};
|
|
2469
|
+
assert!(w > 13, "open of {tok:?} failed");
|
|
2470
|
+
let id_len = u32::from_le_bytes([out[9], out[10], out[11], out[12]]) as usize;
|
|
2471
|
+
out[13..13 + id_len].to_vec()
|
|
2472
|
+
};
|
|
2473
|
+
let mut scratch = [0u8; 256];
|
|
2474
|
+
let truncated_id = id_of(&truncated_str, &mut scratch);
|
|
2475
|
+
let exact_id = id_of(&exact_str, &mut scratch);
|
|
2476
|
+
assert_eq!(truncated_id, b"sess-9");
|
|
2477
|
+
// The byte form carries the exact length, so the id comes back WHOLE — and
|
|
2478
|
+
// byte-exact, because `open_core` now decodes the id rather than returning
|
|
2479
|
+
// the escaped span.
|
|
2480
|
+
assert_eq!(exact_id, &nul_id[..]);
|
|
2481
|
+
|
|
2482
|
+
// Null pointers are rejected, never dereferenced.
|
|
2483
|
+
assert!(unsafe {
|
|
2484
|
+
castrum_session_seal_bytes(
|
|
2485
|
+
std::ptr::null(),
|
|
2486
|
+
0,
|
|
2487
|
+
std::ptr::null(),
|
|
2488
|
+
0,
|
|
2489
|
+
0,
|
|
2490
|
+
std::ptr::null(),
|
|
2491
|
+
0,
|
|
2492
|
+
)
|
|
2493
|
+
}
|
|
2494
|
+
.is_null());
|
|
2495
|
+
assert_eq!(
|
|
2496
|
+
unsafe {
|
|
2497
|
+
castrum_session_open_bytes(
|
|
2498
|
+
std::ptr::null(),
|
|
2499
|
+
0,
|
|
2500
|
+
secret.as_ptr(),
|
|
2501
|
+
secret.len(),
|
|
2502
|
+
buf2.as_mut_ptr(),
|
|
2503
|
+
buf2.len(),
|
|
2504
|
+
)
|
|
2505
|
+
},
|
|
2506
|
+
0
|
|
2507
|
+
);
|
|
2311
2508
|
}
|
|
2312
2509
|
|
|
2313
2510
|
#[test]
|
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]
|
package/rust/http/headers.rs
CHANGED
|
@@ -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")]);
|
package/rust/http/multipart.rs
CHANGED
|
@@ -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(
|