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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. 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(&param[..eq])
107
+ .trim()
108
+ .to_ascii_lowercase();
109
+ if name.is_empty() {
110
+ continue;
111
+ }
112
+ let raw_val = trim_ascii_whitespace(&param[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
+ }