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.
- 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,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
|
+
}
|