castrum 0.9.2 → 0.9.4
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 +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// rust/http/http_parser.rs
|
|
2
|
+
use crate::util::{write_bytes, write_u32_le};
|
|
3
|
+
use napi::bindgen_prelude::*;
|
|
4
|
+
use napi_derive::napi;
|
|
5
|
+
|
|
6
|
+
pub fn http_parse_request_packed_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
|
|
7
|
+
let mut headers = [httparse::EMPTY_HEADER; 64];
|
|
8
|
+
let mut req = httparse::Request::new(&mut headers);
|
|
9
|
+
|
|
10
|
+
match req.parse(input) {
|
|
11
|
+
Ok(httparse::Status::Complete(_)) => {}
|
|
12
|
+
Ok(_) => return Err(Error::new(Status::InvalidArg, "incomplete HTTP request")),
|
|
13
|
+
Err(e) => return Err(Error::new(Status::InvalidArg, e.to_string())),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let method = req
|
|
17
|
+
.method
|
|
18
|
+
.ok_or_else(|| Error::new(Status::InvalidArg, "missing HTTP method"))?
|
|
19
|
+
.as_bytes();
|
|
20
|
+
|
|
21
|
+
let path = req
|
|
22
|
+
.path
|
|
23
|
+
.ok_or_else(|| Error::new(Status::InvalidArg, "missing HTTP path"))?
|
|
24
|
+
.as_bytes();
|
|
25
|
+
|
|
26
|
+
let version: &[u8] = match req.version {
|
|
27
|
+
Some(0) => b"HTTP/1.0",
|
|
28
|
+
Some(1) => b"HTTP/1.1",
|
|
29
|
+
_ => b"HTTP/1.1",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let mut pos = 0usize;
|
|
33
|
+
|
|
34
|
+
write_u32_le(out, &mut pos, method.len() as u32)?;
|
|
35
|
+
write_bytes(out, &mut pos, method)?;
|
|
36
|
+
|
|
37
|
+
write_u32_le(out, &mut pos, path.len() as u32)?;
|
|
38
|
+
write_bytes(out, &mut pos, path)?;
|
|
39
|
+
|
|
40
|
+
write_u32_le(out, &mut pos, version.len() as u32)?;
|
|
41
|
+
write_bytes(out, &mut pos, version)?;
|
|
42
|
+
|
|
43
|
+
write_u32_le(out, &mut pos, req.headers.len() as u32)?;
|
|
44
|
+
|
|
45
|
+
for header in req.headers.iter() {
|
|
46
|
+
let name = header.name.as_bytes();
|
|
47
|
+
let value = header.value;
|
|
48
|
+
|
|
49
|
+
write_u32_le(out, &mut pos, name.len() as u32)?;
|
|
50
|
+
crate::util::write_bytes_lowercase(out, &mut pos, name)?;
|
|
51
|
+
|
|
52
|
+
write_u32_le(out, &mut pos, value.len() as u32)?;
|
|
53
|
+
crate::util::write_bytes(out, &mut pos, value)?;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Ok(pos)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Allocating, zero-copy-input, non-zeroing-output parser.
|
|
60
|
+
///
|
|
61
|
+
/// Delegates to [`http_parse_request_packed_into_slice`] so the httparse→
|
|
62
|
+
/// packed-write logic lives in exactly one place (`_vec` and `_into_slice` can
|
|
63
|
+
/// never drift — see the `into_slice_and_vec_agree` parity test).
|
|
64
|
+
pub fn http_parse_request_packed_vec(input: &[u8]) -> Result<Vec<u8>> {
|
|
65
|
+
// Metadata overhead bound: 16 bytes of section headers + 8 bytes per
|
|
66
|
+
// header length prefix (max 64 headers) = 16 + 64 * 8 = 528.
|
|
67
|
+
let mut out = vec![0u8; input.len().saturating_add(528)];
|
|
68
|
+
let written = http_parse_request_packed_into_slice(input, &mut out)?;
|
|
69
|
+
out.truncate(written);
|
|
70
|
+
Ok(out)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[napi]
|
|
74
|
+
pub fn http_parse_request_packed(input: Uint8Array) -> Result<Buffer> {
|
|
75
|
+
Ok(Buffer::from(http_parse_request_packed_vec(input.as_ref())?))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[napi]
|
|
79
|
+
pub fn http_parse_request_packed_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
|
|
80
|
+
crate::util::run_packed_into(&input, &mut output, http_parse_request_packed_into_slice)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#[cfg(test)]
|
|
84
|
+
mod tests {
|
|
85
|
+
use super::*;
|
|
86
|
+
|
|
87
|
+
/// Decode the packed request layout produced by
|
|
88
|
+
/// [`http_parse_request_packed_into_slice`]:
|
|
89
|
+
/// `[u32 method_len][method][u32 path_len][path][u32 version_len][version]
|
|
90
|
+
/// [u32 header_count]{ [u32 name_len][name][u32 value_len][value] }`.
|
|
91
|
+
#[allow(clippy::type_complexity)]
|
|
92
|
+
fn decode_packed(packed: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>, Vec<(Vec<u8>, Vec<u8>)>) {
|
|
93
|
+
let mut pos = 0usize;
|
|
94
|
+
fn take_len(p: &[u8], pos: &mut usize) -> usize {
|
|
95
|
+
let n = u32::from_le_bytes([p[*pos], p[*pos + 1], p[*pos + 2], p[*pos + 3]]) as usize;
|
|
96
|
+
*pos += 4;
|
|
97
|
+
n
|
|
98
|
+
}
|
|
99
|
+
fn take<'a>(p: &'a [u8], pos: &mut usize, n: usize) -> &'a [u8] {
|
|
100
|
+
let s = &p[*pos..*pos + n];
|
|
101
|
+
*pos += n;
|
|
102
|
+
s
|
|
103
|
+
}
|
|
104
|
+
let method = {
|
|
105
|
+
let n = take_len(packed, &mut pos);
|
|
106
|
+
take(packed, &mut pos, n).to_vec()
|
|
107
|
+
};
|
|
108
|
+
let path = {
|
|
109
|
+
let n = take_len(packed, &mut pos);
|
|
110
|
+
take(packed, &mut pos, n).to_vec()
|
|
111
|
+
};
|
|
112
|
+
let version = {
|
|
113
|
+
let n = take_len(packed, &mut pos);
|
|
114
|
+
take(packed, &mut pos, n).to_vec()
|
|
115
|
+
};
|
|
116
|
+
let count = take_len(packed, &mut pos);
|
|
117
|
+
let mut headers = Vec::with_capacity(count);
|
|
118
|
+
for _ in 0..count {
|
|
119
|
+
let name = {
|
|
120
|
+
let n = take_len(packed, &mut pos);
|
|
121
|
+
take(packed, &mut pos, n).to_vec()
|
|
122
|
+
};
|
|
123
|
+
let value = {
|
|
124
|
+
let n = take_len(packed, &mut pos);
|
|
125
|
+
take(packed, &mut pos, n).to_vec()
|
|
126
|
+
};
|
|
127
|
+
headers.push((name, value));
|
|
128
|
+
}
|
|
129
|
+
(method, path, version, headers)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[allow(clippy::type_complexity)]
|
|
133
|
+
fn parse_once(
|
|
134
|
+
input: &[u8],
|
|
135
|
+
) -> std::result::Result<(Vec<u8>, Vec<u8>, Vec<u8>, Vec<(Vec<u8>, Vec<u8>)>), napi::Error>
|
|
136
|
+
{
|
|
137
|
+
let mut out = vec![0u8; 4096];
|
|
138
|
+
let written = http_parse_request_packed_into_slice(input, &mut out)?;
|
|
139
|
+
Ok(decode_packed(&out[..written]))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn basic_get_parse_and_lowercase() {
|
|
144
|
+
let (method, path, version, headers) = parse_once(
|
|
145
|
+
b"GET /api/users?page=2 HTTP/1.1\r\nHost: Example.COM\r\nX-Custom-Header: v\r\n\r\n",
|
|
146
|
+
)
|
|
147
|
+
.unwrap();
|
|
148
|
+
assert_eq!(method, b"GET");
|
|
149
|
+
assert_eq!(path, b"/api/users?page=2");
|
|
150
|
+
assert_eq!(version, b"HTTP/1.1");
|
|
151
|
+
assert_eq!(
|
|
152
|
+
headers,
|
|
153
|
+
vec![
|
|
154
|
+
(b"host".to_vec(), b"Example.COM".to_vec()),
|
|
155
|
+
(b"x-custom-header".to_vec(), b"v".to_vec()),
|
|
156
|
+
]
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn http10_maps_version() {
|
|
162
|
+
let (_, _, version, _) = parse_once(b"GET / HTTP/1.0\r\n\r\n").unwrap();
|
|
163
|
+
assert_eq!(version, b"HTTP/1.0");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn incomplete_request_is_error() {
|
|
168
|
+
// No terminating blank line → httparse returns Partial → error.
|
|
169
|
+
assert!(parse_once(b"GET / HTTP/1.1\r\nHost: a").is_err());
|
|
170
|
+
assert!(parse_once(b"").is_err());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn too_many_headers_is_error() {
|
|
175
|
+
// The parser's header array is sized 64; httparse refuses more.
|
|
176
|
+
let mut req = String::from("GET / HTTP/1.1\r\n");
|
|
177
|
+
for i in 0..80 {
|
|
178
|
+
req.push_str(&format!("h{i}: v\r\n"));
|
|
179
|
+
}
|
|
180
|
+
req.push_str("\r\n");
|
|
181
|
+
assert!(parse_once(req.as_bytes()).is_err());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn header_value_whitespace_preserved() {
|
|
186
|
+
let (_, _, _, headers) = parse_once(
|
|
187
|
+
b"GET / HTTP/1.1\r\nAccept: text/html, application/json\r\nX-B: a b\r\n\r\n",
|
|
188
|
+
)
|
|
189
|
+
.unwrap();
|
|
190
|
+
assert_eq!(
|
|
191
|
+
headers[0],
|
|
192
|
+
(b"accept".to_vec(), b"text/html, application/json".to_vec())
|
|
193
|
+
);
|
|
194
|
+
assert_eq!(headers[1], (b"x-b".to_vec(), b"a b".to_vec()));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn absolute_form_target_preserved() {
|
|
199
|
+
let (_, path, _, _) =
|
|
200
|
+
parse_once(b"GET http://example.com/path?q=1 HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
|
201
|
+
.unwrap();
|
|
202
|
+
assert_eq!(path, b"http://example.com/path?q=1");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
fn missing_method_is_error() {
|
|
207
|
+
assert!(parse_once(b"HTTP/1.1\r\n\r\n").is_err());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#[test]
|
|
211
|
+
fn null_byte_in_header_rejected() {
|
|
212
|
+
assert!(parse_once(b"GET / HTTP/1.1\r\nX: a\x00b\r\n\r\n").is_err());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn duplicate_headers_all_preserved() {
|
|
217
|
+
let (_, _, _, headers) = parse_once(
|
|
218
|
+
b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\nSet-Cookie: x=1\r\nSet-Cookie: y=2\r\n\r\n",
|
|
219
|
+
)
|
|
220
|
+
.unwrap();
|
|
221
|
+
assert_eq!(headers[0], (b"host".to_vec(), b"a".to_vec()));
|
|
222
|
+
assert_eq!(headers[1], (b"host".to_vec(), b"b".to_vec()));
|
|
223
|
+
assert_eq!(headers[2], (b"set-cookie".to_vec(), b"x=1".to_vec()));
|
|
224
|
+
assert_eq!(headers[3], (b"set-cookie".to_vec(), b"y=2".to_vec()));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn content_length_and_transfer_encoding_pass_through() {
|
|
229
|
+
// The parser is format-only: it must not interpret or drop either
|
|
230
|
+
// header (semantic conflict resolution is a later pipeline concern).
|
|
231
|
+
let (_, _, _, headers) = parse_once(
|
|
232
|
+
b"POST / HTTP/1.1\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n",
|
|
233
|
+
)
|
|
234
|
+
.unwrap();
|
|
235
|
+
assert_eq!(headers[0], (b"content-length".to_vec(), b"5".to_vec()));
|
|
236
|
+
assert_eq!(
|
|
237
|
+
headers[1],
|
|
238
|
+
(b"transfer-encoding".to_vec(), b"chunked".to_vec())
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn into_slice_and_vec_agree() {
|
|
244
|
+
// Parity guard: the allocating and slice writers must emit identical
|
|
245
|
+
// bytes (keeps the dedup refactor of `_vec` → `_into_slice` honest).
|
|
246
|
+
let input = b"GET /a?b=c HTTP/1.1\r\nHost: x\r\nX-Mixed: V\r\n\r\n";
|
|
247
|
+
let mut out = vec![0u8; 4096];
|
|
248
|
+
let written = http_parse_request_packed_into_slice(input, &mut out).unwrap();
|
|
249
|
+
let vec_out = http_parse_request_packed_vec(input).unwrap();
|
|
250
|
+
assert_eq!(&out[..written], vec_out.as_slice());
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// rust/http/media_type.rs — Content-Type / media type parser.
|
|
2
|
+
//
|
|
3
|
+
// Parses `type/subtype; param=value; ...` per RFC 7231 §3.1.1.1: lowercased
|
|
4
|
+
// type/subtype, token + quoted-string params (charset, boundary). Pure-Rust
|
|
5
|
+
// core (`parse_media_type_core`) stays napi-free for testability; only the
|
|
6
|
+
// entry points use napi types.
|
|
7
|
+
|
|
8
|
+
use std::collections::HashMap;
|
|
9
|
+
|
|
10
|
+
use napi::bindgen_prelude::*;
|
|
11
|
+
use napi_derive::napi;
|
|
12
|
+
|
|
13
|
+
use crate::util::bytes::trim_ascii_whitespace;
|
|
14
|
+
|
|
15
|
+
/// RFC 7230 `tchar` (tokens used in media-type names/params).
|
|
16
|
+
#[inline]
|
|
17
|
+
fn is_token_char(b: u8) -> bool {
|
|
18
|
+
matches!(
|
|
19
|
+
b,
|
|
20
|
+
b'a'..=b'z'
|
|
21
|
+
| b'A'..=b'Z'
|
|
22
|
+
| b'0'..=b'9'
|
|
23
|
+
| b'!'
|
|
24
|
+
| b'#'
|
|
25
|
+
| b'$'
|
|
26
|
+
| b'%'
|
|
27
|
+
| b'&'
|
|
28
|
+
| b'\''
|
|
29
|
+
| b'*'
|
|
30
|
+
| b'+'
|
|
31
|
+
| b'-'
|
|
32
|
+
| b'.'
|
|
33
|
+
| b'^'
|
|
34
|
+
| b'_'
|
|
35
|
+
| b'`'
|
|
36
|
+
| b'|'
|
|
37
|
+
| b'~'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// A parsed media type (pure-Rust core type).
|
|
42
|
+
pub struct ParsedMediaType {
|
|
43
|
+
pub ty: String,
|
|
44
|
+
pub subtype: String,
|
|
45
|
+
pub params: Vec<(String, String)>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Decode a quoted-string param value (`"..."`, backslash escapes).
|
|
49
|
+
fn parse_quoted(raw: &[u8]) -> std::result::Result<String, &'static str> {
|
|
50
|
+
let mut out = Vec::with_capacity(raw.len());
|
|
51
|
+
let mut i = 1;
|
|
52
|
+
while i < raw.len() {
|
|
53
|
+
match raw[i] {
|
|
54
|
+
b'"' => return String::from_utf8(out).map_err(|_| "invalid UTF-8 in quoted string"),
|
|
55
|
+
b'\\' if i + 1 < raw.len() => {
|
|
56
|
+
out.push(raw[i + 1]);
|
|
57
|
+
i += 2;
|
|
58
|
+
}
|
|
59
|
+
b => {
|
|
60
|
+
out.push(b);
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
Err("unterminated quoted string")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Parse `type/subtype; param=value` (params may be quoted or token values).
|
|
69
|
+
pub fn parse_media_type_core(input: &[u8]) -> std::result::Result<ParsedMediaType, &'static str> {
|
|
70
|
+
let semi = input.iter().position(|&b| b == b';');
|
|
71
|
+
let (main, rest) = match semi {
|
|
72
|
+
Some(i) => (&input[..i], &input[i + 1..]),
|
|
73
|
+
None => (input, &[][..]),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let main = trim_ascii_whitespace(main);
|
|
77
|
+
let slash = main
|
|
78
|
+
.iter()
|
|
79
|
+
.position(|&b| b == b'/')
|
|
80
|
+
.ok_or("missing '/' in media type")?;
|
|
81
|
+
// Single-pass lowercase: `to_ascii_lowercase()` into a Vec, then
|
|
82
|
+
// `String::from_utf8` wraps it without a second copy (the old
|
|
83
|
+
// `from_utf8_lossy(...).into_owned()` copied twice). Input is
|
|
84
|
+
// token-validated below, so invalid UTF-8 is rejected either way.
|
|
85
|
+
let ty = String::from_utf8(main[..slash].to_ascii_lowercase())
|
|
86
|
+
.map_err(|_| "invalid UTF-8 in media type")?;
|
|
87
|
+
let subtype = String::from_utf8(main[slash + 1..].to_ascii_lowercase())
|
|
88
|
+
.map_err(|_| "invalid UTF-8 in media type")?;
|
|
89
|
+
if ty.is_empty() || subtype.is_empty() {
|
|
90
|
+
return Err("empty type or subtype");
|
|
91
|
+
}
|
|
92
|
+
if !ty.bytes().all(is_token_char) || !subtype.bytes().all(is_token_char) {
|
|
93
|
+
return Err("invalid type/subtype token");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let mut params = Vec::new();
|
|
97
|
+
for param in rest.split(|&b| b == b';') {
|
|
98
|
+
let param = trim_ascii_whitespace(param);
|
|
99
|
+
if param.is_empty() {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let eq = param
|
|
103
|
+
.iter()
|
|
104
|
+
.position(|&b| b == b'=')
|
|
105
|
+
.ok_or("media-type param missing '='")?;
|
|
106
|
+
let name = String::from_utf8_lossy(¶m[..eq])
|
|
107
|
+
.trim()
|
|
108
|
+
.to_ascii_lowercase();
|
|
109
|
+
if name.is_empty() {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let raw_val = trim_ascii_whitespace(¶m[eq + 1..]);
|
|
113
|
+
let value = if raw_val.first() == Some(&b'"') {
|
|
114
|
+
parse_quoted(raw_val)?
|
|
115
|
+
} else {
|
|
116
|
+
let v = trim_ascii_whitespace(raw_val);
|
|
117
|
+
if v.is_empty() {
|
|
118
|
+
return Err("empty media-type param value");
|
|
119
|
+
}
|
|
120
|
+
String::from_utf8_lossy(v).into_owned()
|
|
121
|
+
};
|
|
122
|
+
params.push((name, value));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
Ok(ParsedMediaType {
|
|
126
|
+
ty,
|
|
127
|
+
subtype,
|
|
128
|
+
params,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// napi-projected structured result (snake_case → camelCase in JS).
|
|
133
|
+
#[napi(object)]
|
|
134
|
+
pub struct MediaTypeResult {
|
|
135
|
+
/// Lowercased `type/subtype`.
|
|
136
|
+
pub media_type: String,
|
|
137
|
+
pub charset: Option<String>,
|
|
138
|
+
pub boundary: Option<String>,
|
|
139
|
+
pub params: HashMap<String, String>,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[napi]
|
|
143
|
+
pub fn parse_media_type(input: Uint8Array) -> Result<MediaTypeResult> {
|
|
144
|
+
let parsed =
|
|
145
|
+
parse_media_type_core(input.as_ref()).map_err(|e| Error::from_reason(e.to_string()))?;
|
|
146
|
+
let mut params = HashMap::with_capacity(parsed.params.len());
|
|
147
|
+
for (k, v) in parsed.params {
|
|
148
|
+
params.insert(k, v);
|
|
149
|
+
}
|
|
150
|
+
Ok(MediaTypeResult {
|
|
151
|
+
charset: params.get("charset").cloned(),
|
|
152
|
+
boundary: params.get("boundary").cloned(),
|
|
153
|
+
media_type: format!("{}/{}", parsed.ty, parsed.subtype),
|
|
154
|
+
params,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Zero-alloc media-type split: returns the `(type, subtype)` byte slices from
|
|
159
|
+
/// `input`, replicating `parse_media_type_core`'s type/subtype acceptance
|
|
160
|
+
/// rules (parameters stripped, whitespace-trimmed, non-empty, RFC 7230 token
|
|
161
|
+
/// chars) WITHOUT building the lowercase `String`s or the params `Vec`. The
|
|
162
|
+
/// caller compares case-insensitively against a precompiled lowercase expected
|
|
163
|
+
/// side. Any non-token byte (including non-ASCII / invalid UTF-8) is rejected,
|
|
164
|
+
/// exactly like the parser.
|
|
165
|
+
#[inline]
|
|
166
|
+
fn split_type_subtype(input: &[u8]) -> Option<(&[u8], &[u8])> {
|
|
167
|
+
let semi = memchr::memchr(b';', input);
|
|
168
|
+
let main = match semi {
|
|
169
|
+
Some(i) => &input[..i],
|
|
170
|
+
None => input,
|
|
171
|
+
};
|
|
172
|
+
let main = trim_ascii_whitespace(main);
|
|
173
|
+
let slash = memchr::memchr(b'/', main)?;
|
|
174
|
+
let ty = &main[..slash];
|
|
175
|
+
let subtype = &main[slash + 1..];
|
|
176
|
+
if ty.is_empty() || subtype.is_empty() {
|
|
177
|
+
return None;
|
|
178
|
+
}
|
|
179
|
+
if !ty.iter().all(|&b| is_token_char(b)) || !subtype.iter().all(|&b| is_token_char(b)) {
|
|
180
|
+
return None;
|
|
181
|
+
}
|
|
182
|
+
Some((ty, subtype))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Higher-order instance: provides a reusable `matches` (wildcard negotiation)
|
|
186
|
+
/// helper and a consistent instance-based API for content-type handling.
|
|
187
|
+
#[napi]
|
|
188
|
+
pub struct MediaTypeParser;
|
|
189
|
+
|
|
190
|
+
impl Default for MediaTypeParser {
|
|
191
|
+
fn default() -> Self {
|
|
192
|
+
Self
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[napi]
|
|
197
|
+
impl MediaTypeParser {
|
|
198
|
+
#[napi(constructor)]
|
|
199
|
+
pub fn new() -> Self {
|
|
200
|
+
Self
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[napi]
|
|
204
|
+
pub fn parse(&self, input: Uint8Array) -> Result<MediaTypeResult> {
|
|
205
|
+
parse_media_type(input)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Higher-order instance: precompiles the EXPECTED media type once at
|
|
210
|
+
/// construction (type/subtype lowercased), so every `matches` call only parses
|
|
211
|
+
/// the ACTUAL header — the expected side never re-parses.
|
|
212
|
+
#[napi]
|
|
213
|
+
pub struct MediaTypeMatcher {
|
|
214
|
+
expected_ty: String,
|
|
215
|
+
expected_subtype: String,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[napi]
|
|
219
|
+
impl MediaTypeMatcher {
|
|
220
|
+
/// Precompile `expected` (`*/*`, `type/*`, or `type/subtype`).
|
|
221
|
+
#[napi(constructor)]
|
|
222
|
+
pub fn new(expected: Uint8Array) -> Result<Self> {
|
|
223
|
+
let parsed = parse_media_type_core(expected.as_ref())
|
|
224
|
+
.map_err(|e| Error::from_reason(e.to_string()))?;
|
|
225
|
+
Ok(Self {
|
|
226
|
+
expected_ty: parsed.ty,
|
|
227
|
+
expected_subtype: parsed.subtype,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Wildcard match against the precompiled expected type. Only `actual` is
|
|
232
|
+
/// inspected per call — `expected` was normalized once at construction, and
|
|
233
|
+
/// the actual side is split into byte slices (no String / params-Vec alloc).
|
|
234
|
+
#[napi]
|
|
235
|
+
pub fn matches(&self, actual: Uint8Array) -> bool {
|
|
236
|
+
let Some((ty, subtype)) = split_type_subtype(actual.as_ref()) else {
|
|
237
|
+
return false;
|
|
238
|
+
};
|
|
239
|
+
let ty_ok = self.expected_ty == "*"
|
|
240
|
+
|| crate::util::bytes::ascii_eq_ignore_case(ty, self.expected_ty.as_bytes());
|
|
241
|
+
let st_ok = self.expected_subtype == "*"
|
|
242
|
+
|| crate::util::bytes::ascii_eq_ignore_case(subtype, self.expected_subtype.as_bytes());
|
|
243
|
+
ty_ok && st_ok
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Opaque handle to the precompiled expected type, for the `bun:ffi` C-ABI
|
|
247
|
+
/// fast path (`castrum_media_type_matcher_matches` in rust/ffi.rs). Only
|
|
248
|
+
/// valid while THIS instance is alive; the JS wrapper holds the instance.
|
|
249
|
+
#[napi]
|
|
250
|
+
pub fn inner_ptr(&self) -> u64 {
|
|
251
|
+
self as *const MediaTypeMatcher as u64
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// C-ABI support: wildcard match against the precompiled expected type.
|
|
256
|
+
///
|
|
257
|
+
/// # Safety
|
|
258
|
+
/// `p` must be a valid `*const MediaTypeMatcher` from `inner_ptr`, alive for
|
|
259
|
+
/// the call (the JS wrapper holds the napi instance).
|
|
260
|
+
pub(crate) unsafe fn media_type_matcher_matches_core(
|
|
261
|
+
p: *const MediaTypeMatcher,
|
|
262
|
+
actual: &[u8],
|
|
263
|
+
) -> bool {
|
|
264
|
+
let this = &*p;
|
|
265
|
+
let Some((ty, subtype)) = split_type_subtype(actual) else {
|
|
266
|
+
return false;
|
|
267
|
+
};
|
|
268
|
+
let ty_ok = this.expected_ty == "*"
|
|
269
|
+
|| crate::util::bytes::ascii_eq_ignore_case(ty, this.expected_ty.as_bytes());
|
|
270
|
+
let st_ok = this.expected_subtype == "*"
|
|
271
|
+
|| crate::util::bytes::ascii_eq_ignore_case(subtype, this.expected_subtype.as_bytes());
|
|
272
|
+
ty_ok && st_ok
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[cfg(test)]
|
|
276
|
+
mod tests {
|
|
277
|
+
use super::*;
|
|
278
|
+
|
|
279
|
+
fn parse(s: &str) -> ParsedMediaType {
|
|
280
|
+
parse_media_type_core(s.as_bytes()).unwrap()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#[test]
|
|
284
|
+
fn parses_basic_json() {
|
|
285
|
+
let m = parse("application/json; charset=utf-8");
|
|
286
|
+
assert_eq!(m.ty, "application");
|
|
287
|
+
assert_eq!(m.subtype, "json");
|
|
288
|
+
assert_eq!(m.params, vec![("charset".to_string(), "utf-8".to_string())]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[test]
|
|
292
|
+
fn parses_multipart_boundary() {
|
|
293
|
+
let m = parse("multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW");
|
|
294
|
+
assert_eq!(m.ty, "multipart");
|
|
295
|
+
assert_eq!(m.subtype, "form-data");
|
|
296
|
+
assert_eq!(m.params[0].0, "boundary");
|
|
297
|
+
assert_eq!(m.params[0].1, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn lowercases_type_and_subtype() {
|
|
302
|
+
let m = parse("Application/JSON; Charset=UTF-8");
|
|
303
|
+
assert_eq!(m.ty, "application");
|
|
304
|
+
assert_eq!(m.subtype, "json");
|
|
305
|
+
assert_eq!(m.params[0], ("charset".to_string(), "UTF-8".to_string()));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn parses_quoted_param_with_escape() {
|
|
310
|
+
let m = parse("text/plain; filename=\"a\\\"b.txt\"");
|
|
311
|
+
assert_eq!(
|
|
312
|
+
m.params[0],
|
|
313
|
+
("filename".to_string(), "a\"b.txt".to_string())
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[test]
|
|
318
|
+
fn trims_whitespace_and_skips_empty() {
|
|
319
|
+
let m = parse(" text/html ; charset=utf-8 ;; q=1 ");
|
|
320
|
+
assert_eq!(m.ty, "text");
|
|
321
|
+
assert_eq!(m.subtype, "html");
|
|
322
|
+
assert_eq!(m.params.len(), 2);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#[test]
|
|
326
|
+
fn matcher_precompiles_expected() {
|
|
327
|
+
// The precompiled instance only parses `actual` per call — expected is
|
|
328
|
+
// normalized once at construction. Behavior must match MediaTypeParser.
|
|
329
|
+
let m = MediaTypeMatcher::new(Uint8Array::new(b"Application/JSON".to_vec())).unwrap();
|
|
330
|
+
assert!(m.matches(Uint8Array::new(b"application/json".to_vec())));
|
|
331
|
+
assert!(!m.matches(Uint8Array::new(b"text/html".to_vec())));
|
|
332
|
+
|
|
333
|
+
let any = MediaTypeMatcher::new(Uint8Array::new(b"*/*".to_vec())).unwrap();
|
|
334
|
+
assert!(any.matches(Uint8Array::new(b"text/html".to_vec())));
|
|
335
|
+
assert!(any.matches(Uint8Array::new(b"application/json".to_vec())));
|
|
336
|
+
|
|
337
|
+
let subtype = MediaTypeMatcher::new(Uint8Array::new(b"application/*".to_vec())).unwrap();
|
|
338
|
+
assert!(subtype.matches(Uint8Array::new(b"application/xml".to_vec())));
|
|
339
|
+
assert!(!subtype.matches(Uint8Array::new(b"text/xml".to_vec())));
|
|
340
|
+
|
|
341
|
+
// Malformed actual → false (never panics).
|
|
342
|
+
assert!(!m.matches(Uint8Array::new(b"no-slash".to_vec())));
|
|
343
|
+
|
|
344
|
+
// Malformed expected → construction error.
|
|
345
|
+
assert!(MediaTypeMatcher::new(Uint8Array::new(b"no-slash".to_vec())).is_err());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#[test]
|
|
349
|
+
fn rejects_malformed() {
|
|
350
|
+
assert!(parse_media_type_core(b"application").is_err());
|
|
351
|
+
assert!(parse_media_type_core(b"application/").is_err());
|
|
352
|
+
assert!(parse_media_type_core(b"a/b; x=\"unterminated").is_err());
|
|
353
|
+
assert!(parse_media_type_core(b"a b/c").is_err());
|
|
354
|
+
}
|
|
355
|
+
}
|