castrum 0.9.3 → 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,685 @@
1
+ // rust/http/multipart.rs — multipart/form-data parser (hand-rolled).
2
+ //
3
+ // Backend-framework feature: upload / form body parsing. Deliberately hand-rolled
4
+ // (memchr-based boundary + header splitting) instead of pulling in `multer` and
5
+ // its `http`/async dependency stack — matches this crate's byte-machinery style
6
+ // (cf. query_parser.rs, cookie_parser.rs). It borrows from the input buffer; only
7
+ // the NAPI layer materializes owned strings/Buffers.
8
+ //
9
+ // Pure-Rust core (no napi types) stays unit-testable; only the entry points
10
+ // use napi types.
11
+
12
+ use memchr::memmem;
13
+ use napi::bindgen_prelude::*;
14
+ use napi_derive::napi;
15
+
16
+ use crate::util::bytes::trim_ascii_whitespace;
17
+
18
+ // ── Pure-Rust core ─────────────────────────────────────────────
19
+
20
+ pub struct Part<'a> {
21
+ pub name: &'a [u8],
22
+ pub filename: Option<&'a [u8]>,
23
+ pub content_type: Option<&'a [u8]>,
24
+ pub data: &'a [u8],
25
+ }
26
+
27
+ /// Locate the next `\r\n--{boundary}` delimiter starting at `from`, returning
28
+ /// the index of the leading `\r\n`.
29
+ fn next_delimiter(body: &[u8], boundary: &[u8], from: usize) -> Option<usize> {
30
+ let marker = b"\r\n--";
31
+ let mut base = from;
32
+ while base < body.len() {
33
+ let rel = memmem::find(&body[base..], marker)?;
34
+ let delim = base + rel;
35
+ let after = delim + marker.len();
36
+ if body.get(after..after + boundary.len()) == Some(boundary) {
37
+ return Some(delim);
38
+ }
39
+ base = after;
40
+ }
41
+ None
42
+ }
43
+
44
+ /// Parse `Content-Disposition: form-data; name="..."; filename="..."` →
45
+ /// (name, filename).
46
+ ///
47
+ /// Quote-aware: `;` inside a double-quoted value (e.g. a filename containing a
48
+ /// semicolon) is NOT a parameter separator. `\"` inside a value is skipped for
49
+ /// quote-tracking (the backslash itself is preserved in the value — this
50
+ /// parser does not unescape, matching most server parsers' tolerant handling).
51
+ fn parse_disposition(value: &[u8]) -> (Option<&[u8]>, Option<&[u8]>) {
52
+ let mut name = None;
53
+ let mut filename = None;
54
+ let mut start = 0usize;
55
+ let mut in_quotes = false;
56
+ let mut i = 0usize;
57
+ while i < value.len() {
58
+ match value[i] {
59
+ // Skip an escaped quote so it does not toggle `in_quotes`.
60
+ b'\\' if i + 1 < value.len() && value[i + 1] == b'"' => {
61
+ i += 2;
62
+ continue;
63
+ }
64
+ b'"' => in_quotes = !in_quotes,
65
+ b';' if !in_quotes => {
66
+ parse_disposition_attr(&value[start..i], &mut name, &mut filename);
67
+ start = i + 1;
68
+ }
69
+ _ => {}
70
+ }
71
+ i += 1;
72
+ }
73
+ parse_disposition_attr(&value[start..], &mut name, &mut filename);
74
+ (name, filename)
75
+ }
76
+
77
+ /// Parse one `key=value` attribute of a Content-Disposition value.
78
+ fn parse_disposition_attr<'a>(
79
+ attr: &'a [u8],
80
+ name: &mut Option<&'a [u8]>,
81
+ filename: &mut Option<&'a [u8]>,
82
+ ) {
83
+ let attr = trim_ascii_whitespace(attr);
84
+ if attr.is_empty() {
85
+ return;
86
+ }
87
+ let Some(eq) = memchr::memchr(b'=', attr) else {
88
+ return;
89
+ };
90
+ let key = trim_ascii_whitespace(&attr[..eq]);
91
+ let mut val = trim_ascii_whitespace(&attr[eq + 1..]);
92
+ if val.len() >= 2 && val[0] == b'"' && val[val.len() - 1] == b'"' {
93
+ val = &val[1..val.len() - 1];
94
+ }
95
+ if key.eq_ignore_ascii_case(b"name") {
96
+ *name = Some(val);
97
+ } else if key.eq_ignore_ascii_case(b"filename") {
98
+ *filename = Some(val);
99
+ }
100
+ }
101
+
102
+ /// Parse a `multipart/form-data` body given the raw boundary token (no
103
+ /// surrounding quotes/dashes — the caller passes the value of the
104
+ /// `boundary=` parameter). Returns the list of parts (name, optional filename,
105
+ /// optional content-type, raw data bytes).
106
+ /// Limits for the multipart parser (DoS guard).
107
+ ///
108
+ /// The defaults are generous enough for legitimate uploads but bound the worst
109
+ /// case so an attacker cannot force unbounded memory/CPU from a single body.
110
+ #[derive(Clone, Copy)]
111
+ pub struct Limits {
112
+ /// Maximum number of parts (fields + files) parsed.
113
+ pub max_parts: usize,
114
+ /// Maximum number of plain (non-file) fields.
115
+ pub max_field_count: usize,
116
+ /// Maximum bytes for a single part's data.
117
+ pub max_part_bytes: usize,
118
+ /// Maximum total part-data bytes across the whole body.
119
+ pub max_total_bytes: usize,
120
+ }
121
+
122
+ impl Default for Limits {
123
+ fn default() -> Self {
124
+ Self {
125
+ max_parts: 1_000,
126
+ max_field_count: 1_000,
127
+ max_part_bytes: 10 * 1024 * 1024, // 10 MiB
128
+ max_total_bytes: 64 * 1024 * 1024, // 64 MiB
129
+ }
130
+ }
131
+ }
132
+
133
+ /// Parse a multipart/form-data body into parts, honoring `limits`.
134
+ /// Parsing stops (returns what it has) once a limit is crossed — it never
135
+ /// allocates beyond the configured bounds.
136
+ pub fn parse_multipart_limited<'a>(
137
+ body: &'a [u8],
138
+ boundary: &[u8],
139
+ limits: &Limits,
140
+ ) -> Vec<Part<'a>> {
141
+ let mut parts = Vec::new();
142
+ if boundary.is_empty() {
143
+ return parts;
144
+ }
145
+
146
+ // Body must begin with the opening delimiter `--{boundary}`.
147
+ if !body.starts_with(b"--") || body.get(2..2 + boundary.len()) != Some(boundary) {
148
+ return parts;
149
+ }
150
+ let mut pos = 2 + boundary.len();
151
+ let mut total_data: usize = 0;
152
+ // Running count of non-file fields (enforces max_field_count in O(1) per
153
+ // part instead of recounting every iteration).
154
+ let mut field_count: usize = 0;
155
+
156
+ loop {
157
+ // Closing delimiter (`--{boundary}--`) stops parsing.
158
+ if body.get(pos..pos + 2) == Some(b"--") {
159
+ break;
160
+ }
161
+ // Expect `\r\n` then the part.
162
+ if body.get(pos..pos + 2) != Some(b"\r\n") {
163
+ break;
164
+ }
165
+ pos += 2;
166
+
167
+ // Part headers end at `\r\n\r\n`.
168
+ let Some(header_end_rel) = memmem::find(&body[pos..], b"\r\n\r\n") else {
169
+ break;
170
+ };
171
+ let header_block = &body[pos..pos + header_end_rel];
172
+ let data_start = pos + header_end_rel + 4;
173
+
174
+ let mut name: Option<&[u8]> = None;
175
+ let mut filename: Option<&[u8]> = None;
176
+ let mut content_type: Option<&[u8]> = None;
177
+
178
+ for line in header_block.split(|&b| b == b'\n') {
179
+ let line = if line.ends_with(b"\r") {
180
+ &line[..line.len() - 1]
181
+ } else {
182
+ line
183
+ };
184
+ let Some(colon) = memchr::memchr(b':', line) else {
185
+ continue;
186
+ };
187
+ let field = trim_ascii_whitespace(&line[..colon]);
188
+ let value = trim_ascii_whitespace(&line[colon + 1..]);
189
+ if field.eq_ignore_ascii_case(b"content-disposition") {
190
+ let (n, f) = parse_disposition(value);
191
+ name = n;
192
+ filename = f;
193
+ } else if field.eq_ignore_ascii_case(b"content-type") {
194
+ content_type = Some(value);
195
+ }
196
+ }
197
+
198
+ let Some(delim) = next_delimiter(body, boundary, data_start) else {
199
+ break;
200
+ };
201
+ let data = &body[data_start..delim];
202
+
203
+ // ── Limits enforcement ──
204
+ if parts.len() >= limits.max_parts {
205
+ break;
206
+ }
207
+ if filename.is_none() {
208
+ if field_count >= limits.max_field_count {
209
+ break;
210
+ }
211
+ field_count += 1;
212
+ }
213
+ if data.len() > limits.max_part_bytes {
214
+ break;
215
+ }
216
+ if total_data.saturating_add(data.len()) > limits.max_total_bytes {
217
+ break;
218
+ }
219
+ total_data = total_data.saturating_add(data.len());
220
+
221
+ parts.push(Part {
222
+ name: name.unwrap_or_default(),
223
+ filename,
224
+ content_type,
225
+ data,
226
+ });
227
+
228
+ // Advance past `\r\n--{boundary}`.
229
+ pos = delim + 2 + 2 + boundary.len();
230
+ }
231
+
232
+ parts
233
+ }
234
+
235
+ /// Parse a multipart/form-data body into parts with default limits.
236
+ pub fn parse_multipart<'a>(body: &'a [u8], boundary: &[u8]) -> Vec<Part<'a>> {
237
+ parse_multipart_limited(body, boundary, &Limits::default())
238
+ }
239
+
240
+ // ── NAPI entry points ──────────────────────────────────────────
241
+
242
+ /// One parsed multipart part (owned, JS-visible).
243
+ #[napi(object)]
244
+ pub struct MultipartPart {
245
+ pub name: String,
246
+ pub filename: Option<String>,
247
+ pub content_type: Option<String>,
248
+ pub data: Buffer,
249
+ }
250
+
251
+ fn to_owned(p: &Part<'_>) -> MultipartPart {
252
+ MultipartPart {
253
+ name: String::from_utf8_lossy(p.name).into_owned(),
254
+ filename: p.filename.map(|f| String::from_utf8_lossy(f).into_owned()),
255
+ content_type: p
256
+ .content_type
257
+ .map(|c| String::from_utf8_lossy(c).into_owned()),
258
+ data: Buffer::from(p.data.to_vec()),
259
+ }
260
+ }
261
+
262
+ /// Optional limits for `multipart_parse` (DoS guard). Omitted fields fall back
263
+ /// to generous defaults (see `Limits`).
264
+ #[napi(object)]
265
+ pub struct MultipartLimitsInput {
266
+ pub max_parts: Option<u32>,
267
+ pub max_field_count: Option<u32>,
268
+ pub max_part_bytes: Option<i64>,
269
+ pub max_total_bytes: Option<i64>,
270
+ }
271
+
272
+ impl MultipartLimitsInput {
273
+ fn resolve(&self) -> Limits {
274
+ let d = Limits::default();
275
+ Limits {
276
+ max_parts: self.max_parts.map(|v| v as usize).unwrap_or(d.max_parts),
277
+ max_field_count: self
278
+ .max_field_count
279
+ .map(|v| v as usize)
280
+ .unwrap_or(d.max_field_count),
281
+ max_part_bytes: self
282
+ .max_part_bytes
283
+ .map(|v| v.max(0) as usize)
284
+ .unwrap_or(d.max_part_bytes),
285
+ max_total_bytes: self
286
+ .max_total_bytes
287
+ .map(|v| v.max(0) as usize)
288
+ .unwrap_or(d.max_total_bytes),
289
+ }
290
+ }
291
+ }
292
+
293
+ /// Parse a multipart/form-data body into parts, bounded by `limits`.
294
+ #[napi]
295
+ pub fn multipart_parse(
296
+ body: Uint8Array,
297
+ boundary: Uint8Array,
298
+ limits: Option<MultipartLimitsInput>,
299
+ ) -> Vec<MultipartPart> {
300
+ let limits = limits.map(|l| l.resolve()).unwrap_or_default();
301
+ parse_multipart_limited(body.as_ref(), boundary.as_ref(), &limits)
302
+ .iter()
303
+ .map(to_owned)
304
+ .collect()
305
+ }
306
+
307
+ /// Parse a multipart/form-data body into the packed parts layout used by the
308
+ /// batch API (`[u32 count]{[u32 name_len][name][has_filename][filename][ct][data]}`)
309
+ /// — a zero-copy scalar sibling of the object-returning `multipart_parse`.
310
+ /// Skips the per-part `String` allocations + data copy + N-API objects of the
311
+ /// object path (the dominant cost of multipart parsing on the JS side).
312
+ #[napi]
313
+ pub fn multipart_parse_packed(
314
+ body: Uint8Array,
315
+ boundary: Uint8Array,
316
+ limits: Option<MultipartLimitsInput>,
317
+ ) -> Result<Buffer> {
318
+ let limits = limits.map(|l| l.resolve()).unwrap_or_default();
319
+ let parts = parse_multipart_limited(body.as_ref(), boundary.as_ref(), &limits);
320
+ let mut out = Vec::with_capacity(4 + parts.len() * 64);
321
+ parts_to_packed(&parts, &mut out);
322
+ Ok(Buffer::from(out))
323
+ }
324
+
325
+ /// Serialize parsed parts to the packed layout used by the batch API:
326
+ /// `[u32 count] { [u32 name_len][name][u32 has_filename][u32 filename_len][filename][u32 ct_len][ct][u32 data_len][data] }`.
327
+ pub fn parts_to_packed(parts: &[Part<'_>], out: &mut Vec<u8>) {
328
+ out.extend_from_slice(&(parts.len() as u32).to_le_bytes());
329
+ for p in parts {
330
+ out.extend_from_slice(&(p.name.len() as u32).to_le_bytes());
331
+ out.extend_from_slice(p.name);
332
+ match p.filename {
333
+ Some(f) => {
334
+ out.push(1);
335
+ out.extend_from_slice(&(f.len() as u32).to_le_bytes());
336
+ out.extend_from_slice(f);
337
+ }
338
+ None => {
339
+ out.push(0);
340
+ out.extend_from_slice(&0u32.to_le_bytes());
341
+ }
342
+ }
343
+ match p.content_type {
344
+ Some(c) => {
345
+ out.push(1);
346
+ out.extend_from_slice(&(c.len() as u32).to_le_bytes());
347
+ out.extend_from_slice(c);
348
+ }
349
+ None => {
350
+ out.push(0);
351
+ out.extend_from_slice(&0u32.to_le_bytes());
352
+ }
353
+ }
354
+ out.extend_from_slice(&(p.data.len() as u32).to_le_bytes());
355
+ out.extend_from_slice(p.data);
356
+ }
357
+ }
358
+
359
+ /// Parallel multipart parse batch: packed `[u32 count]{[u32 len][body]}` in →
360
+ /// packed `[u32 count]{[u32 len][parts_packed]}` out (same boundary for all).
361
+ #[napi]
362
+ pub fn multipart_parse_batch_packed(data: Uint8Array, boundary: Uint8Array) -> Result<Buffer> {
363
+ crate::util::run_packed_batch(data.as_ref(), |body| {
364
+ let mut buf = Vec::new();
365
+ parts_to_packed(&parse_multipart(body, boundary.as_ref()), &mut buf);
366
+ buf
367
+ })
368
+ .map(Buffer::from)
369
+ }
370
+
371
+ #[cfg(test)]
372
+ mod tests {
373
+ use super::*;
374
+
375
+ const BOUNDARY: &[u8] = b"----WebKitFormBoundary7MA4YWxkTrZu0gW";
376
+
377
+ fn body(name: &str, filename: Option<&str>, ct: Option<&str>, data: &[u8]) -> Vec<u8> {
378
+ let mut b = Vec::new();
379
+ b.extend_from_slice(b"--");
380
+ b.extend_from_slice(BOUNDARY);
381
+ b.extend_from_slice(b"\r\n");
382
+ b.extend_from_slice(b"Content-Disposition: form-data; name=\"");
383
+ b.extend_from_slice(name.as_bytes());
384
+ if let Some(f) = filename {
385
+ b.extend_from_slice(b"\"; filename=\"");
386
+ b.extend_from_slice(f.as_bytes());
387
+ }
388
+ b.extend_from_slice(b"\"");
389
+ if let Some(ct) = ct {
390
+ b.extend_from_slice(b"\r\nContent-Type: ");
391
+ b.extend_from_slice(ct.as_bytes());
392
+ }
393
+ b.extend_from_slice(b"\r\n\r\n");
394
+ b.extend_from_slice(data);
395
+ b.extend_from_slice(b"\r\n--");
396
+ b.extend_from_slice(BOUNDARY);
397
+ b
398
+ }
399
+
400
+ #[test]
401
+ fn parses_single_field() {
402
+ let mut b = body("field1", None, None, b"hello world");
403
+ b.extend_from_slice(b"--\r\n");
404
+ let parts = parse_multipart(&b, BOUNDARY);
405
+ assert_eq!(parts.len(), 1);
406
+ assert_eq!(parts[0].name, b"field1");
407
+ assert_eq!(parts[0].data, b"hello world");
408
+ assert!(parts[0].filename.is_none());
409
+ assert!(parts[0].content_type.is_none());
410
+ }
411
+
412
+ #[test]
413
+ fn parses_multiple_parts() {
414
+ let mut b = body("field1", None, None, b"alpha");
415
+ b.extend_from_slice(
416
+ b"\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nbeta\r\n--",
417
+ );
418
+ b.extend_from_slice(BOUNDARY);
419
+ b.extend_from_slice(b"--\r\n");
420
+ let parts = parse_multipart(&b, BOUNDARY);
421
+ assert_eq!(parts.len(), 2);
422
+ assert_eq!(parts[0].name, b"field1");
423
+ assert_eq!(parts[0].data, b"alpha");
424
+ assert_eq!(parts[1].name, b"field2");
425
+ assert_eq!(parts[1].data, b"beta");
426
+ }
427
+
428
+ #[test]
429
+ fn packed_scalar_matches_parts() {
430
+ let mut b = body("file", Some("a.txt"), Some("text/plain"), b"file contents");
431
+ b.extend_from_slice(b"--\r\n");
432
+ let parts = parse_multipart(&b, BOUNDARY);
433
+ assert_eq!(parts.len(), 1);
434
+
435
+ let mut expected = Vec::new();
436
+ parts_to_packed(&parts, &mut expected);
437
+
438
+ // The scalar packed API writes exactly the same bytes as the object
439
+ // path's packed projection — a zero-copy sibling of `multipart_parse`.
440
+ let out = multipart_parse_packed(
441
+ Uint8Array::new(b.clone()),
442
+ Uint8Array::new(BOUNDARY.to_vec()),
443
+ None,
444
+ )
445
+ .unwrap();
446
+ assert_eq!(out.to_vec(), expected);
447
+
448
+ // Layout: [count][name_len][name][has_filename][filename_len][filename][ct][data]
449
+ let packed = out.to_vec();
450
+ assert_eq!(&packed[..4], &1u32.to_le_bytes());
451
+ let name_len = u32::from_le_bytes(packed[4..8].try_into().unwrap()) as usize;
452
+ assert_eq!(&packed[8..8 + name_len], b"file");
453
+ assert_eq!(packed[8 + name_len], 1); // has filename
454
+ }
455
+
456
+ #[test]
457
+ fn parses_file_with_filename_and_type() {
458
+ let mut b = body(
459
+ "upload",
460
+ Some("a.txt"),
461
+ Some("text/plain"),
462
+ b"file contents",
463
+ );
464
+ b.extend_from_slice(b"--\r\n");
465
+ let parts = parse_multipart(&b, BOUNDARY);
466
+ assert_eq!(parts.len(), 1);
467
+ assert_eq!(parts[0].name, b"upload");
468
+ assert_eq!(parts[0].filename.unwrap(), b"a.txt");
469
+ assert_eq!(parts[0].content_type.unwrap(), b"text/plain");
470
+ assert_eq!(parts[0].data, b"file contents");
471
+ }
472
+
473
+ #[test]
474
+ fn parses_binary_data() {
475
+ let mut b = body("bin", None, None, b"\x00\x01\x02\xff");
476
+ b.extend_from_slice(b"--\r\n");
477
+ let parts = parse_multipart(&b, BOUNDARY);
478
+ assert_eq!(parts[0].data, b"\x00\x01\x02\xff");
479
+ }
480
+
481
+ #[test]
482
+ fn rejects_missing_opening_delimiter() {
483
+ assert!(parse_multipart(b"no delimiter here", BOUNDARY).is_empty());
484
+ assert!(parse_multipart(b"", BOUNDARY).is_empty());
485
+ assert!(parse_multipart(b"abc", b"").is_empty());
486
+ }
487
+
488
+ #[test]
489
+ fn limits_cap_part_count() {
490
+ // Regression: the parser must bound work when limits are set.
491
+ let mut b = body("field1", None, None, b"alpha");
492
+ b.extend_from_slice(
493
+ b"\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nbeta\r\n--",
494
+ );
495
+ b.extend_from_slice(BOUNDARY);
496
+ b.extend_from_slice(b"--\r\n");
497
+
498
+ let unlimited = parse_multipart(&b, BOUNDARY);
499
+ assert_eq!(unlimited.len(), 2);
500
+
501
+ let capped = parse_multipart_limited(
502
+ &b,
503
+ BOUNDARY,
504
+ &Limits {
505
+ max_parts: 1,
506
+ ..Limits::default()
507
+ },
508
+ );
509
+ assert_eq!(capped.len(), 1);
510
+ assert_eq!(capped[0].name, b"field1");
511
+ }
512
+
513
+ #[test]
514
+ fn limits_cap_part_bytes() {
515
+ let mut b = body("field1", None, None, b"very long field data");
516
+ b.extend_from_slice(b"--\r\n");
517
+
518
+ let capped = parse_multipart_limited(
519
+ &b,
520
+ BOUNDARY,
521
+ &Limits {
522
+ max_part_bytes: 4, // smaller than the part data
523
+ ..Limits::default()
524
+ },
525
+ );
526
+ assert!(capped.is_empty(), "oversized part must be dropped");
527
+ }
528
+
529
+ #[test]
530
+ fn limits_cap_total_bytes() {
531
+ let mut b = body("field1", None, None, b"alpha");
532
+ b.extend_from_slice(
533
+ b"\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nbeta\r\n--",
534
+ );
535
+ b.extend_from_slice(BOUNDARY);
536
+ b.extend_from_slice(b"--\r\n");
537
+
538
+ let capped = parse_multipart_limited(
539
+ &b,
540
+ BOUNDARY,
541
+ &Limits {
542
+ max_total_bytes: 4, // only "alpha" (5) would fit? no -> stop before any
543
+ max_part_bytes: usize::MAX,
544
+ max_field_count: usize::MAX,
545
+ max_parts: usize::MAX,
546
+ },
547
+ );
548
+ assert!(capped.len() < 2, "total-byte limit must stop parsing");
549
+ }
550
+
551
+ #[test]
552
+ fn handles_crlf_only_body() {
553
+ // Just the opening + closing delimiter with no parts.
554
+ let mut b = Vec::new();
555
+ b.extend_from_slice(b"--");
556
+ b.extend_from_slice(BOUNDARY);
557
+ b.extend_from_slice(b"--\r\n");
558
+ let parts = parse_multipart(&b, BOUNDARY);
559
+ assert!(parts.is_empty());
560
+ }
561
+
562
+ #[test]
563
+ fn quoted_semicolon_in_filename_preserved() {
564
+ // A `;` inside the quoted filename is data, not a separator.
565
+ let mut b = body("up", Some("b;c.txt"), None, b"data");
566
+ b.extend_from_slice(b"--\r\n");
567
+ let parts = parse_multipart(&b, BOUNDARY);
568
+ assert_eq!(parts.len(), 1);
569
+ assert_eq!(parts[0].filename.unwrap(), b"b;c.txt");
570
+ }
571
+
572
+ #[test]
573
+ fn quoted_value_with_spaces_preserved() {
574
+ let mut b = body("up", Some("my file.txt"), None, b"data");
575
+ b.extend_from_slice(b"--\r\n");
576
+ let parts = parse_multipart(&b, BOUNDARY);
577
+ assert_eq!(parts[0].filename.unwrap(), b"my file.txt");
578
+ }
579
+
580
+ #[test]
581
+ fn escaped_quote_in_value_preserved() {
582
+ // The `\"` must not break quote tracking (the backslash itself is kept).
583
+ let mut b = Vec::new();
584
+ b.extend_from_slice(b"--");
585
+ b.extend_from_slice(BOUNDARY);
586
+ b.extend_from_slice(b"\r\n");
587
+ b.extend_from_slice(
588
+ b"Content-Disposition: form-data; name=\"up\"; filename=\"a\\\"b.txt\"\r\n\r\n",
589
+ );
590
+ b.extend_from_slice(b"data");
591
+ b.extend_from_slice(b"\r\n--");
592
+ b.extend_from_slice(BOUNDARY);
593
+ b.extend_from_slice(b"--\r\n");
594
+ let parts = parse_multipart(&b, BOUNDARY);
595
+ assert_eq!(parts.len(), 1);
596
+ assert_eq!(parts[0].name, b"up");
597
+ assert_eq!(parts[0].filename.unwrap(), b"a\\\"b.txt");
598
+ }
599
+
600
+ #[test]
601
+ fn boundary_like_data_mid_part() {
602
+ // A bare `--{boundary}` inside data (no leading CRLF) is NOT a
603
+ // delimiter; only `\r\n--{boundary}` is.
604
+ let mut b = body(
605
+ "f",
606
+ None,
607
+ None,
608
+ b"abc --WebKitFormBoundary7MA4YWxkTrZu0gW def",
609
+ );
610
+ b.extend_from_slice(b"--\r\n");
611
+ let parts = parse_multipart(&b, BOUNDARY);
612
+ assert_eq!(parts.len(), 1);
613
+ assert_eq!(
614
+ parts[0].data,
615
+ b"abc --WebKitFormBoundary7MA4YWxkTrZu0gW def"
616
+ );
617
+ }
618
+
619
+ #[test]
620
+ fn epilogue_after_closing_delimiter_ignored() {
621
+ let mut b = body("f", None, None, b"data");
622
+ b.extend_from_slice(b"--\r\n\r\nignored epilogue trailing garbage");
623
+ let parts = parse_multipart(&b, BOUNDARY);
624
+ assert_eq!(parts.len(), 1);
625
+ assert_eq!(parts[0].name, b"f");
626
+ assert_eq!(parts[0].data, b"data");
627
+ }
628
+
629
+ #[test]
630
+ fn missing_name_attr_parses_empty() {
631
+ let mut b = Vec::new();
632
+ b.extend_from_slice(b"--");
633
+ b.extend_from_slice(BOUNDARY);
634
+ b.extend_from_slice(b"\r\nContent-Disposition: form-data\r\n\r\ndata\r\n--");
635
+ b.extend_from_slice(BOUNDARY);
636
+ b.extend_from_slice(b"--\r\n");
637
+ let parts = parse_multipart(&b, BOUNDARY);
638
+ assert_eq!(parts.len(), 1);
639
+ assert!(parts[0].name.is_empty());
640
+ assert_eq!(parts[0].data, b"data");
641
+ }
642
+
643
+ #[test]
644
+ fn empty_filename() {
645
+ let mut b = body("up", Some(""), None, b"data");
646
+ b.extend_from_slice(b"--\r\n");
647
+ let parts = parse_multipart(&b, BOUNDARY);
648
+ assert_eq!(parts.len(), 1);
649
+ assert_eq!(parts[0].filename.unwrap(), b"");
650
+ }
651
+
652
+ #[test]
653
+ fn max_field_count_limit() {
654
+ let mut b = body("f1", None, None, b"a");
655
+ b.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"f2\"\r\n\r\nb\r\n--");
656
+ b.extend_from_slice(BOUNDARY);
657
+ b.extend_from_slice(b"--\r\n");
658
+
659
+ let capped = parse_multipart_limited(
660
+ &b,
661
+ BOUNDARY,
662
+ &Limits {
663
+ max_field_count: 1,
664
+ ..Limits::default()
665
+ },
666
+ );
667
+ assert_eq!(capped.len(), 1);
668
+ assert_eq!(capped[0].name, b"f1");
669
+ }
670
+
671
+ #[test]
672
+ fn crlf_only_part_headers() {
673
+ // A part whose header block is empty: `\r\n` then `\r\n\r\n` then data.
674
+ let mut b = Vec::new();
675
+ b.extend_from_slice(b"--");
676
+ b.extend_from_slice(BOUNDARY);
677
+ b.extend_from_slice(b"\r\n\r\n\r\nbare data\r\n--");
678
+ b.extend_from_slice(BOUNDARY);
679
+ b.extend_from_slice(b"--\r\n");
680
+ let parts = parse_multipart(&b, BOUNDARY);
681
+ assert_eq!(parts.len(), 1);
682
+ assert_eq!(parts[0].data, b"bare data");
683
+ assert!(parts[0].name.is_empty());
684
+ }
685
+ }