castrum 0.9.7 → 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 +1 -1
- 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 +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- 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 +6 -0
- package/rust/ffi/crypto.rs +63 -32
- 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 +24 -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 +10 -259
- 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.ts +2 -434
- 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/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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
return
|
|
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
|
-
|
|
751
|
-
|
|
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
|
}
|
package/rust/ffi/metrics.rs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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).
|
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):
|
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(
|