1import { Buffer } from 'node:buffer';
2import { badRequest, tooLarge } from './errors.ts';
3import { collect, Input } from './streams.ts';
4
5export function parameters(raw: string): { type: string; params: Map<string, string> } {
6 const separator = raw.indexOf(';');
7 const type = (separator === -1 ? raw : raw.slice(0, separator)).trim().toLowerCase();
8 let rest = separator === -1 ? '' : raw.slice(separator);
9 const params = new Map<string, string>();
10 while (rest.length) {
11 const match = /^;\s*([\w-]+)\s*=\s*(?:"((?:[^"\\\r\n]|\\[^\r\n])*)"|([^\s;]+))\s*/.exec(rest);
12 if (!match) badRequest();
13 const name = match[1]!.toLowerCase();
14 if (params.has(name)) badRequest();
15 params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3]!);
16 rest = rest.slice(match[0].length);
17 }
18 return { type, params };
19}
20
21export function boundaryFrom(contentType: string | null): string {
22 const { type, params } = parameters(contentType ?? '');
23 const boundary = params.get('boundary');
24 if (
25 type !== 'multipart/form-data' ||
26 !boundary ||
27 boundary.length > 70 ||
28 !/^[0-9A-Za-z'()+_,./:=? -]+$/.test(boundary) ||
29 boundary.endsWith(' ')
30 )
31 badRequest();
32 return boundary;
33}
34
35export async function* multipart(
36 input: Input,
37 boundary: string,
38 headerLimit: number,
39): AsyncGenerator<{
40 name: 'metadata' | 'blob';
41 body: AsyncGenerator<Uint8Array>;
42}> {
43 let preamble = 0;
44 while (true) {
45 const line = await input.until(Buffer.from('\r\n'), headerLimit);
46 preamble += line.length + 2;
47 if (preamble > headerLimit) tooLarge();
48 if (line.toString('utf8').replace(/[ \t]+$/, '') === `--${boundary}`) break;
49 }
50 const delimiter = Buffer.from(`\r\n--${boundary}`);
51 const seen = new Set<string>();
52 let closed = false;
53 while (!closed) {
54 if (seen.size >= 2) badRequest();
55 const raw = await input.until(Buffer.from('\r\n\r\n'), headerLimit);
56 const headers = new Map<string, string>();
57 for (const line of raw.toString('utf8').split('\r\n')) {
58 const match = /^([!#$%&'*+.^_`|~\w-]+):[ \t]*([^\r\n]*)$/.exec(line);
59 if (!match) badRequest();
60 const name = match[1]!.toLowerCase();
61 if (headers.has(name)) badRequest();
62 headers.set(name, match[2]!.trim());
63 }
64 const disposition = parameters(headers.get('content-disposition') ?? '');
65 const name = disposition.params.get('name');
66 if (
67 disposition.type !== 'form-data' ||
68 (name !== 'metadata' && name !== 'blob') ||
69 seen.has(name)
70 )
71 badRequest();
72 if (headers.has('content-transfer-encoding')) badRequest();
73 const expected = name === 'metadata' ? 'application/cbor' : 'application/octet-stream';
74 if (parameters(headers.get('content-type') ?? '').type !== expected) badRequest();
75 seen.add(name);
76 let consumed = false;
77 async function* body(): AsyncGenerator<Uint8Array> {
78 while (true) {
79 let index = input.buffer.indexOf(delimiter);
80 while (index >= 0) {
81 const match = await delimiterEnd(input, index + delimiter.length, headerLimit);
82 if (match) {
83 if (index) yield input.take(index);
84 input.take(match.end - index);
85 closed = match.closed;
86 consumed = true;
87 return;
88 }
89 index = input.buffer.indexOf(delimiter, index + 1);
90 }
91 const safe = input.buffer.length - delimiter.length - 2;
92 if (safe > 0) yield input.take(safe);
93 if (!(await input.fill())) badRequest();
94 }
95 }
96 yield { name, body: body() };
97 if (!consumed) throw new Error('Multipart consumer must drain each part');
98 }
99 if (!seen.has('metadata')) badRequest();
100 await collect(input.rest(), headerLimit);
101}
102
103async function delimiterEnd(
104 input: Input,
105 start: number,
106 limit: number,
107): Promise<{ end: number; closed: boolean } | null> {
108 while (input.buffer.length < start + 2 && (await input.fill())) {}
109 const closed = input.buffer[start] === 45 && input.buffer[start + 1] === 45;
110 let end = start + (closed ? 2 : 0);
111 while (true) {
112 while (input.buffer.length < end + 2 && (await input.fill())) {}
113 if (input.buffer[end] !== 32 && input.buffer[end] !== 9) break;
114 if (++end - start > limit) tooLarge();
115 }
116 if (input.buffer[end] === 13 && input.buffer[end + 1] === 10) return { end: end + 2, closed };
117 if (closed && input.ended && end === input.buffer.length) return { end, closed };
118 // A boundary prefix followed by arbitrary bytes is still part of the opaque body.
119 return null;
120}