1import * as Node from "@effect/platform-node/NodeMultipartParser"
2import * as ByteSize from "effect/ByteSize"
3import * as Multipart from "effect/http/MultipartParser"
4import { assert, describe, expectTypeOf, test } from "vitest"
5
6type Expected = Array<
7 | [type: "field", name: string, value: string, contentType: string]
8 | [
9 type: "file",
10 name: string,
11 bytesReceived: number,
12 filename: string,
13 contentType: string
14 ]
15>
16
17interface MultipartCase {
18 readonly config?: Partial<Multipart.Config>
19 readonly name: string
20 readonly source: ReadonlyArray<string>
21 readonly boundary: string
22 readonly expected: Expected
23 readonly errors?: ReadonlyArray<Multipart.MultipartError["_tag"]>
24}
25
26const cases: ReadonlyArray<MultipartCase> = [
27 {
28 source: [
29 [
30 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
31 "Content-Disposition: form-data; name=\"file_name_0\"",
32 "",
33 "super alpha file",
34 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
35 "Content-Disposition: form-data; name=\"file_name_1\"",
36 "",
37 "super beta file",
38 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
39 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"1k_a.dat\"",
40 "Content-Type: application/octet-stream",
41 "",
42 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
43 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
44 "Content-Disposition: form-data; name=\"upload_file_1\"; filename=\"1k_b.dat\"",
45 "Content-Type: application/octet-stream",
46 "",
47 "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
48 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
49 ].join("\r\n")
50 ],
51 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
52 expected: [
53 ["field", "file_name_0", "super alpha file", "text/plain"],
54 ["field", "file_name_1", "super beta file", "text/plain"],
55 ["file", "upload_file_0", 1023, "1k_a.dat", "application/octet-stream"],
56 ["file", "upload_file_1", 1023, "1k_b.dat", "application/octet-stream"]
57 ],
58 name: "Fields and files"
59 },
60 {
61 source: [
62 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k\r\n",
63 `Content-Disposition: form-data; name="file_name_0"\r\n`,
64 "\r\n",
65 "super alpha file\r\n",
66 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k\r\n",
67 "Content-Disposition: form-data; name=\"file_name_1\"\r\n",
68 "\r\n",
69 "super beta file\r\n",
70 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k\r\n",
71 `Content-Disposition: form-data; name="upload_file_0"; filename="1k_a.dat"\r\n`,
72 "Content-Type: application/octet-stream\r\n",
73 "\r\n",
74 "A".repeat(1024 * 1024),
75 "A".repeat(1024 * 1024),
76 "A".repeat(1024 * 1024),
77 "A".repeat(1024 * 1024),
78 "A".repeat(1024 * 1024),
79 "A".repeat(1024 * 1024),
80 "A".repeat(1024 * 1024),
81 "A".repeat(1024 * 1024),
82 "A".repeat(1024 * 1024),
83 "A".repeat(1024 * 1024),
84 "\r\n-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
85 ],
86 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
87 expected: [
88 ["field", "file_name_0", "super alpha file", "text/plain"],
89 ["field", "file_name_1", "super beta file", "text/plain"],
90 [
91 "file",
92 "upload_file_0",
93 1024 * 1024 * 10,
94 "1k_a.dat",
95 "application/octet-stream"
96 ]
97 ],
98 name: "Fields and large file"
99 },
100 {
101 source: [
102 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k\r\n",
103 "Content-Disposition: form-data; name=\"file_name_0\"\r\n",
104 "\r\n",
105 "super alpha file\r\n",
106 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
107 ],
108 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
109 expected: [["field", "file_name_0", "super alpha file", "text/plain"]],
110 name: "Headers over multiple chunks",
111 errors: []
112 },
113 {
114 source: [
115 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
116 "Content-Disposition: form-data; name=\"file_name_0\"",
117 "",
118 "super alpha file",
119 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
120 ]
121 .join("\r\n")
122 .split(""),
123
124 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
125 expected: [["field", "file_name_0", "super alpha file", "text/plain"]],
126 name: "Headers over single byte chunks",
127 errors: []
128 },
129 {
130 source: [
131 [
132 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
133 "Content-Disposition: form-data; name=\"cont\"",
134 "",
135 "some random content",
136 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
137 "Content-Disposition: form-data; name=\"pass\"",
138 "",
139 "some random pass",
140 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
141 "Content-Disposition: form-data; name=\"bit\"",
142 "",
143 "2",
144 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY--"
145 ].join("\r\n")
146 ],
147 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
148 expected: [
149 ["field", "cont", "some random content", "text/plain"],
150 ["field", "pass", "some random pass", "text/plain"],
151 ["field", "bit", "2", "text/plain"]
152 ],
153 name: "Fields only"
154 },
155 {
156 source: [
157 [
158 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
159 "Content-Disposition: form-data; name=\"first\"",
160 "",
161 "A".repeat(32),
162 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
163 "Content-Disposition: form-data; name=\"second\"",
164 "",
165 "B".repeat(32),
166 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY--"
167 ].join("\r\n")
168 ],
169 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
170 config: {
171 maxPartSize: ByteSize.bytes(100)
172 },
173 expected: [
174 ["field", "first", "A".repeat(32), "text/plain"],
175 ["field", "second", "B".repeat(32), "text/plain"]
176 ],
177 errors: [],
178 name: "Resets maxPartSize between field parts"
179 },
180 {
181 source: [],
182 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
183 expected: [],
184 errors: ["EndNotReached"],
185 name: "No fields and no files"
186 },
187 {
188 source: [
189 "--boundary\r\ncontent-disposition: form-data; nam"
190 ],
191 boundary: "boundary",
192 expected: [],
193 errors: ["EndNotReached"],
194 name: "Truncated mid-header"
195 },
196 {
197 source: [
198 [
199 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
200 "Content-Disposition: form-data; name=\"file_name_0\"",
201 "",
202 "super alpha file",
203 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
204 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"1k_a.dat\"",
205 "Content-Type: application/octet-stream",
206 "",
207 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
208 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
209 ].join("\r\n")
210 ],
211 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
212 config: {
213 maxFieldSize: ByteSize.bytes(5)
214 },
215 expected: [],
216 errors: ["ReachedLimit"],
217 name: "stops after maxFieldSize is exceeded"
218 },
219 {
220 source: [
221 [
222 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
223 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"1k_a.dat\"",
224 "Content-Type: application/octet-stream",
225 "",
226 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
227 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
228 ].join("\r\n")
229 ],
230 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
231 config: {
232 maxPartSize: ByteSize.bytes(13)
233 },
234 expected: [],
235 errors: ["ReachedLimit"],
236 name: "stops after maxPartSize is exceeded"
237 },
238 {
239 source: [
240 [
241 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
242 "Content-Disposition: form-data; name=\"file_name_0\"",
243 "",
244 "super alpha file",
245 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
246 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"1k_a.dat\"",
247 "Content-Type: application/octet-stream",
248 "",
249 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
250 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
251 ].join("\r\n")
252 ],
253 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
254 config: {
255 maxParts: 0
256 },
257 expected: [],
258 errors: ["ReachedLimit"],
259 name: "stops before the first part when maxParts is 0"
260 },
261 {
262 source: [
263 [
264 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
265 "Content-Disposition: form-data; name=\"file_name_0\"",
266 "",
267 "super alpha file",
268 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
269 "Content-Disposition: form-data; name=\"file_name_1\"",
270 "",
271 "super beta file",
272 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
273 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"1k_a.dat\"",
274 "Content-Type: application/octet-stream",
275 "",
276 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
277 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
278 ].join("\r\n")
279 ],
280 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
281 config: {
282 maxParts: 1
283 },
284 errors: ["ReachedLimit"],
285 expected: [["field", "file_name_0", "super alpha file", "text/plain"]],
286 name: "stops after maxParts is exceeded"
287 },
288 {
289 source: [
290 [
291 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
292 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"/absolute/1k_a.dat\"",
293 "Content-Type: application/octet-stream",
294 "",
295 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
296 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
297 "Content-Disposition: form-data; name=\"upload_file_1\"; filename=\"C:\\absolute\\1k_b.dat\"",
298 "Content-Type: application/octet-stream",
299 "",
300 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
301 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
302 "Content-Disposition: form-data; name=\"upload_file_2\"; filename=\"relative/1k_c.dat\"",
303 "Content-Type: application/octet-stream",
304 "",
305 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
306 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
307 ].join("\r\n")
308 ],
309 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
310 expected: [
311 [
312 "file",
313 "upload_file_0",
314 26,
315 "/absolute/1k_a.dat",
316 "application/octet-stream"
317 ],
318 [
319 "file",
320 "upload_file_1",
321 26,
322 "C:\\absolute\\1k_b.dat",
323 "application/octet-stream"
324 ],
325 [
326 "file",
327 "upload_file_2",
328 26,
329 "relative/1k_c.dat",
330 "application/octet-stream"
331 ]
332 ],
333 name: "Paths to be preserved"
334 },
335 {
336 source: [
337 [
338 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
339 "Content-Disposition: form-data; name=\"cont\"",
340 "Content-Type: ",
341 "",
342 "some random content",
343 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
344 "Content-Disposition: ",
345 "",
346 "some random pass",
347 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY--"
348 ].join("\r\n")
349 ],
350 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
351 expected: [
352 ["field", "cont", "some random content", "text/plain"],
353 ["field", "", "some random pass", "text/plain"]
354 ],
355 name: "Empty content-type and empty content-disposition"
356 },
357 {
358 config: {
359 isFile: (_) => _.name !== "upload_file_0"
360 },
361 source: [
362 [
363 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
364 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"blob\"",
365 "Content-Type: application/json",
366 "",
367 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
368 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
369 ].join("\r\n")
370 ],
371 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
372 expected: [
373 [
374 "field",
375 "upload_file_0",
376 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
377 "application/json"
378 ]
379 ],
380 name: "Blob uploads should be handled as fields if isFile is provided."
381 },
382 {
383 config: {
384 isFile: (_) => _.name !== "upload_file_0"
385 },
386 source: [
387 [
388 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
389 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"blob\"",
390 "Content-Type: application/json",
391 "",
392 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
393 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
394 "Content-Disposition: form-data; name=\"file\"; filename*=utf-8''n%C3%A4me.txt",
395 "Content-Type: application/octet-stream",
396 "",
397 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
398 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
399 ].join("\r\n")
400 ],
401 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
402 expected: [
403 [
404 "field",
405 "upload_file_0",
406 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
407 "application/json"
408 ],
409 ["file", "file", 26, "näme.txt", "application/octet-stream"]
410 ],
411 name: "Blob uploads should be handled as fields if isFile is provided. Other parts should be files."
412 },
413 {
414 config: {
415 isFile: (_) => _.name === "upload_file_0"
416 },
417 source: [
418 [
419 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
420 "Content-Disposition: form-data; name=\"upload_file_0\"; filename=\"blob\"",
421 "Content-Type: application/json",
422 "",
423 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
424 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
425 "Content-Disposition: form-data; name=\"file\"; filename*=utf-8''n%C3%A4me.txt",
426 "Content-Type: application/octet-stream",
427 "",
428 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
429 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
430 ].join("\r\n")
431 ],
432 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
433 expected: [
434 ["file", "upload_file_0", 26, "blob", "application/json"],
435 [
436 "field",
437 "file",
438 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
439 "application/octet-stream"
440 ]
441 ],
442 name: "Blob uploads sould be handled as files if corresponding isFile is provided. Other parts should be fields."
443 },
444 {
445 source: [
446 [
447 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
448 "Content-Disposition: form-data; name=\"file\"; filename*=utf-8''n%C3%A4me.txt",
449 "Content-Type: application/octet-stream",
450 "",
451 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
452 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
453 ].join("\r\n")
454 ],
455 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
456 expected: [["file", "file", 26, "näme.txt", "application/octet-stream"]],
457 name: "Unicode filenames"
458 },
459 {
460 source: [
461 [
462 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
463 "Content-Disposition: form-data; name=\"file\"; filename*=utf-8''%ZZ",
464 "Content-Type: application/octet-stream",
465 "",
466 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
467 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
468 ].join("\r\n")
469 ],
470 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
471 expected: [["file", "file", 26, "%ZZ", "application/octet-stream"]],
472 name: "Malformed encoded filenames"
473 },
474 {
475 source: [
476 [
477 "--asdasdasdasd\r\n",
478 "Content-Type: text/plain\r\n",
479 "Content-Disposition: form-data; name=\"foo\"\r\n",
480 "\r\n",
481 "asd\r\n",
482 "--asdasdasdasd--"
483 ].join(":)")
484 ],
485 boundary: "asdasdasdasd",
486 expected: [],
487 errors: ["BadHeaders", "EndNotReached"],
488 name: "Stopped mid-header"
489 },
490 {
491 source: [
492 [
493 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY",
494 "Content-Disposition: form-data; name=\"cont\"",
495 "Content-Type: application/json",
496 "",
497 "{}",
498 "------WebKitFormBoundaryTB2MiQ36fnSJlrhY--"
499 ].join("\r\n")
500 ],
501 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
502 expected: [["field", "cont", "{}", "application/json"]],
503 name: "content-type for fields"
504 },
505 {
506 source: ["------WebKitFormBoundaryTB2MiQ36fnSJlrhY--\r\n"],
507 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
508 expected: [],
509 name: "empty form"
510 },
511 {
512 source: [
513 [
514 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
515 "Content-Disposition: form-data; name=\"field1\"",
516 "content-type: text/plain; charset=utf-8",
517 "",
518 "Aufklärung ist der Ausgang des Menschen aus seiner selbstverschuldeten Unmündigkeit.",
519 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
520 "Content-Disposition: form-data; name=\"field2\"",
521 "content-type: text/plain; charset=iso-8859-1",
522 "",
523 "sapere aude!",
524 "-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--"
525 ].join("\r\n")
526 ],
527 boundary: "---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k",
528 expected: [
529 [
530 "field",
531 "field1",
532 "Aufklärung ist der Ausgang des Menschen aus seiner selbstverschuldeten Unmündigkeit.",
533 "text/plain"
534 ],
535 ["field", "field2", "sapere aude!", "text/plain"]
536 ],
537 name: "Fields and files"
538 },
539 {
540 source: [
541 [
542 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
543 "Content-Disposition: form-data; name=\"regsubmit\"",
544 "",
545 "yes",
546 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
547 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
548 "Content-Disposition: form-data; name=\"referer\"",
549 "",
550 "http://domainExample/./",
551 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
552 "Content-Disposition: form-data; name=\"activationauth\"",
553 "",
554 "",
555 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
556 "Content-Disposition: form-data; name=\"seccodemodid\"",
557 "",
558 "member::register",
559 "------WebKitFormBoundaryzca7IDMnT6QwqBp7--"
560 ].join("\r\n")
561 ],
562 boundary: "----WebKitFormBoundaryzca7IDMnT6QwqBp7",
563 expected: [
564 ["field", "regsubmit", "yes", "text/plain"],
565 ["field", "referer", "http://domainExample/./", "text/plain"],
566 ["field", "activationauth", "", "text/plain"],
567 ["field", "seccodemodid", "member::register", "text/plain"]
568 ],
569 name: "one empty part should get ignored"
570 },
571 {
572 source: [" ------WebKitFormBoundaryTB2MiQ36fnSJlrhY--\r\n"],
573 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhY",
574 expected: [],
575 errors: ["EndNotReached"],
576 name: "empty form with preceding whitespace"
577 },
578 {
579 source: ["------WebKitFormBoundaryTB2MiQ36fnSJlrhY--\r\n"],
580 boundary: "----WebKitFormBoundaryTB2MiQ36fnSJlrhYY",
581 expected: [],
582 errors: ["EndNotReached"],
583 name: "empty form with wrong boundary (extra Y)"
584 },
585 {
586 source: [
587 [
588 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
589 "Content-Disposition: form-data; name=\"regsubmit\"",
590 "",
591 "yes",
592 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
593 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
594 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
595 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
596 "Content-Disposition: form-data; name=\"referer\"",
597 "",
598 "http://domainExample/./",
599 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
600 "Content-Disposition: form-data; name=\"activationauth\"",
601 "",
602 "",
603 "------WebKitFormBoundaryzca7IDMnT6QwqBp7",
604 "Content-Disposition: form-data; name=\"seccodemodid\"",
605 "",
606 "member::register",
607 "------WebKitFormBoundaryzca7IDMnT6QwqBp7--"
608 ].join("\r\n")
609 ],
610 boundary: "----WebKitFormBoundaryzca7IDMnT6QwqBp7",
611 expected: [
612 ["field", "regsubmit", "yes", "text/plain"],
613 ["field", "referer", "http://domainExample/./", "text/plain"],
614 ["field", "activationauth", "", "text/plain"],
615 ["field", "seccodemodid", "member::register", "text/plain"]
616 ],
617 name: "multiple empty parts should get ignored"
618 }
619]
620
621describe("multipart", () => {
622 test.each(cases)("$name", (opts) => {
623 const parts: Expected = []
624 const errors: Array<Multipart.MultipartError["_tag"]> = []
625
626 const parser = Multipart.make({
627 ...opts.config,
628 headers: {
629 "content-type": "multipart/form-data; boundary=" + opts.boundary
630 },
631 onFile: (info) => {
632 let size = 0
633 return (chunk) => {
634 if (chunk) {
635 size += chunk.length
636 } else {
637 parts.push([
638 "file",
639 info.name,
640 size,
641 info.filename!,
642 info.contentType
643 ])
644 }
645 }
646 },
647 onField: (info, value) => {
648 parts.push([
649 "field",
650 info.name,
651 Multipart.decodeField(info, value),
652 info.contentType
653 ])
654 },
655 onError: (error) => {
656 errors.push(error._tag)
657 },
658 onDone: () => {}
659 })
660
661 opts.source.forEach((chunk) => {
662 parser.write(new TextEncoder().encode(chunk))
663 })
664 parser.end()
665
666 assert.deepStrictEqual(opts.expected, parts)
667 if (opts.errors) {
668 assert.deepEqual(opts.errors, errors)
669 }
670 })
671})
672
673describe("node api", () => {
674 test("exposes missing filenames as undefined", () => {
675 const parser = Node.make({
676 headers: {
677 "content-type": "multipart/form-data; boundary=boundary"
678 }
679 })
680 let emitted = false
681 parser.on("file", (file) => {
682 emitted = true
683 expectTypeOf(file.filename).toEqualTypeOf<string | undefined>()
684 assert.strictEqual(file.filename, undefined)
685 file.resume()
686 })
687
688 parser.write(
689 new TextEncoder().encode(
690 "--boundary\r\nContent-Disposition: form-data; name=\"file\"\r\nContent-Type: application/octet-stream\r\n\r\ncontent\r\n--boundary--"
691 )
692 )
693 parser.end()
694
695 assert.isTrue(emitted)
696 parser.destroy()
697 })
698
699 test("stops sending file chunks after an error", () => {
700 const parser = Node.make({
701 headers: {
702 "content-type": "multipart/form-data; boundary=boundary"
703 },
704 maxPartSize: ByteSize.bytes(100)
705 })
706 let file: Node.FileStream | undefined
707 parser.on("file", (part) => {
708 file = part
709 part.on("error", () => {})
710 })
711 parser.on("error", () => {})
712
713 parser.write(
714 new TextEncoder().encode(
715 "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\r\nA"
716 )
717 )
718 parser.write(new TextEncoder().encode("B".repeat(128)))
719
720 assert.strictEqual(file?.read().toString(), "A")
721 file?.destroy()
722 parser.destroy()
723 })
724
725 test.each(cases)("$name", (opts) => {
726 const parts: Expected = []
727 const errors: Array<Multipart.MultipartError["_tag"]> = []
728
729 const parser = Node.make({
730 ...opts.config,
731 headers: {
732 "content-type": "multipart/form-data; boundary=" + opts.boundary
733 }
734 })
735 parser.on("field", (field) => {
736 parts.push([
737 "field",
738 field.info.name,
739 Multipart.decodeField(field.info, field.value),
740 field.info.contentType
741 ])
742 })
743 parser.on("file", (file) => {
744 let size = 0
745 file.on("data", (chunk) => {
746 size += chunk.length
747 })
748 file.on("end", () => {
749 parts.push([
750 "file",
751 file.info.name,
752 size,
753 file.info.filename!,
754 file.info.contentType
755 ])
756 })
757 })
758 parser.on("error", (error) => {
759 errors.push(error._tag)
760 })
761
762 parser.on("end", () => {
763 assert.deepStrictEqual(opts.expected, parts)
764 if (opts.errors) {
765 assert.deepEqual(opts.errors, errors)
766 }
767 })
768
769 opts.source.forEach((chunk) => {
770 parser.write(new TextEncoder().encode(chunk))
771 })
772 parser.end()
773 })
774})
775
776describe("node async-iterable api", () => {
777 test.each(cases)("$name", async (opts) => {
778 const parts: Expected = []
779
780 const parser = Node.make({
781 ...opts.config,
782 headers: {
783 "content-type": "multipart/form-data; boundary=" + opts.boundary
784 }
785 })
786 async function read() {
787 for await (const part of parser) {
788 if (part._tag === "Field") {
789 parts.push([
790 "field",
791 part.info.name,
792 Multipart.decodeField(part.info, part.value),
793 part.info.contentType
794 ])
795 } else {
796 let size = 0
797 for await (const chunk of part) {
798 size += chunk.length
799 }
800 parts.push([
801 "file",
802 part.info.name,
803 size,
804 part.info.filename!,
805 part.info.contentType
806 ])
807 }
808 }
809 }
810
811 const readPromise = read()
812
813 opts.source.forEach((chunk) => {
814 parser.write(new TextEncoder().encode(chunk))
815 })
816 parser.end()
817
818 try {
819 await readPromise
820 assert.deepStrictEqual(opts.expected, parts)
821 } catch (err) {
822 if (opts.errors) {
823 assert.strictEqual((err as any)._tag, opts.errors[0])
824 }
825 }
826 })
827})
828
829// Synchronous parsing can starve the async suites.
830describe("random data", { concurrent: false }, () => {
831 test("smoke test", () => {
832 const boundary = "------WebKitFormBoundaryTB2MiQ36fnSJlrhY--"
833 let seed = 0x9e3779b9
834 const random = () => {
835 seed ^= seed << 13
836 seed ^= seed >>> 17
837 seed ^= seed << 5
838 return (seed >>> 0) / 0x1_0000_0000
839 }
840 for (let i = 0; i < 100; i++) {
841 const size = Math.round(random() * 1024 * 1024 * 100)
842 const data = Buffer.alloc(size, i)
843 let success = false
844
845 const parser = Multipart.make({
846 headers: {
847 "content-type": `multipart/form-data; boundary=${boundary}`
848 },
849 onDone() {
850 success = true
851 },
852 onFile() {
853 return () => {}
854 },
855 onField() {},
856 onError() {}
857 })
858
859 const buffer = Buffer.concat([
860 Buffer.from(`--${boundary}\r\n`),
861 Buffer.from(
862 "Content-Disposition: form-data; name=\"file\"; filename=\"blob\"\r\n"
863 ),
864 Buffer.from("Content-Type: application/octet-stream\r\n"),
865 Buffer.from("\r\n"),
866 data,
867 Buffer.from(`\r\n--${boundary}--`)
868 ])
869
870 let cursor = 0
871 while (cursor < buffer.length) {
872 const maxChunkSize = buffer.length - cursor
873 const chunkSize = Math.max(
874 1,
875 Math.min(
876 Math.round(random() * 128 * 1024 * 1024),
877 maxChunkSize
878 )
879 )
880 parser.write(buffer.subarray(cursor, cursor + chunkSize))
881 cursor += chunkSize
882 }
883 parser.end()
884 assert.isTrue(success)
885 }
886 }, 90_000)
887})