src/form.bend
emerging-ezhttp@0.20.0.0 · 343 lines · 0 definitions
1# ezhttp/src/form: multipart/form-data request bodies (RFC 7578), no IO. A
2# form is text fields and file parts whose content is packed Bytes. Every
3# decision is form.plan's, over the parts with each file's content replaced
4# by whether the delimiter occurs in it; the plan lays the body out as runs
5# of octets and file contents, and form.build puts them together, so a
6# file's bytes reach the wire unchanged (SPEC.md HTTP-CLI-10). The caller
7# picks the boundary and the builder refuses one a reader could misread.
8# SPEC.md HTTP-FORM lists what is guaranteed.
9import Base
10import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
11import emerging-ezhttp@0.20.0.0/src/body.bend as Body
12import emerging-ezhttp@0.20.0.0/src/pct.bend as Pct
13
14# one part handed to the builder: a text field, or a file with its name,
15# content type and bytes
16type Upload is Type:
17 UField{name: String, value: String}
18 UFile{name: String, filename: String, ctype: String, bytes: Bytes.Bytes}
19
20# one part as the plan sees it: a text field, or a file whose content is
21# known only by whether the delimiter occurs in it
22type Part is Data:
23 Field{name: String, value: String}
24 File{name: String, filename: String, ctype: String, clash: Bool}
25
26# one run of the body the plan lays out: octets, or the next file's bytes
27type Seg is Data:
28 Run{octets: List<&2, U32>}
29 Blob{}
30
31# a planned form (the Content-Type field value and the body's runs), or why
32# not
33type Plan is Data:
34 NoPlan{why: String}
35 Plan{ctype: String, segs: List<&2, Seg>}
36
37# a built form (the Content-Type field value and the body), or why not
38type Form is Type: # noqa: L001 it holds Bytes, which no law can compare; form.plan's laws cover it (HTTP-TRUST-8)
39 NoForm{why: String}
40 Form{ctype: String, body: Body.Body}
41
42# a text field
43def form.field(name: String, value: String) -> Upload:
44 UField{name, value}
45
46# a file part: its field name, its filename, its content type and its bytes
47def form.file(name: String, filename: String, ctype: String,
48 bytes: Bytes.Bytes) -> Upload:
49 UFile{name, filename, ctype, bytes}
50
51# --- octets ---
52
53# a string's UTF-8 octets in front of `rest` (RFC 3629 §3)
54def octets.onto(s: String, rest: List<&2, U32>) -> List<&2, U32>:
55 match s:
56 case SNil{}:
57 rest
58 case SCon{Chr{+cp}, tail}:
59 List.append(&2, U32, Pct.utf8(cp), octets.onto(tail, rest))
60
61# a check that holds when this does and the rest does: a false ends it
62def all.step(ok: Bool, rest: Unit -> Bool) -> Bool:
63 match ok:
64 case False{}:
65 False{}
66 case True{}:
67 rest(Unit{})
68
69# a search that hits when this does or the rest does: a true ends it
70def any.step(hit: Bool, rest: Unit -> Bool) -> Bool:
71 match hit:
72 case True{}:
73 True{}
74 case False{}:
75 rest(Unit{})
76
77# whether octets open with a pattern's octets
78def octets.starts(xs: List<&2, U32>, pat: List<&2, U32>) -> Bool:
79 match xs pat:
80 case Nil{} Nil{}:
81 True{}
82 case Nil{} Con{p, q}:
83 False{}
84 case Con{x, t} Nil{}:
85 True{}
86 case Con{x, t} Con{p, q}:
87 all.step(U32.is_eq(x, p), _u => octets.starts(t, q))
88
89# whether a pattern's octets occur as a contiguous run anywhere in octets
90def octets.has(xs: List<&2, U32>, +pat: List<&2, U32>) -> Bool:
91 match xs:
92 case []:
93 octets.starts([], pat)
94 case +x <> +t:
95 any.step(octets.starts(x <> t, pat), _u => octets.has(t, pat))
96
97# --- the boundary (RFC 2046 §5.1.1) ---
98
99# a boundary character this builder allows: A-Z a-z 0-9 - _ . (each a bchar
100# of RFC 2046 and a tchar of RFC 9110 §5.6.2, so no quotes are needed)
101def form.bchar(+cp: U32) -> Bool:
102 Bool.or(Bool.and(U32.is_le(65, cp), U32.is_le(cp, 90)),
103 Bool.or(Bool.and(U32.is_le(97, cp), U32.is_le(cp, 122)),
104 Bool.or(Bool.and(U32.is_le(48, cp), U32.is_le(cp, 57)),
105 Bool.or(U32.is_eq(cp, 45),
106 Bool.or(U32.is_eq(cp, 95), U32.is_eq(cp, 46))))))
107
108# every character of a string is a boundary character
109def form.bchars(s: String) -> Bool:
110 match s:
111 case SNil{}:
112 True{}
113 case SCon{Chr{+cp}, tail}:
114 all.step(form.bchar(cp), _u => form.bchars(tail))
115
116# a boundary is 1 to 70 boundary characters
117def form.boundary.ok(+b: String) -> Bool:
118 Bool.and(Bool.and(Bool.not(String.is_empty(b)),
119 Nat.is_le(String.length(b), 70n)), form.bchars(b))
120
121# the delimiter a reader looks for: "--" and the boundary, as octets
122def form.delim(b: String) -> List<&2, U32>:
123 octets.onto("--" ++ b, [])
124
125# --- quoting (RFC 7578 §4.2) ---
126
127# a character that is not `"` or CR: LF as %0A, anything else as itself
128def form.one.lf(+cp: U32, rest: String, lf: Bool) -> String:
129 match lf:
130 case True{}:
131 "%0A" ++ rest
132 case False{}:
133 SCon{Chr{cp}, rest}
134
135# a character that is not `"`: CR as %0D, else on to LF
136def form.one.cr(+cp: U32, rest: String, cr: Bool) -> String:
137 match cr:
138 case True{}:
139 "%0D" ++ rest
140 case False{}:
141 form.one.lf(cp, rest, U32.is_eq(cp, 10))
142
143# a character of a quoted name, once it is known whether it is `"`: as %22
144def form.one.dq(+cp: U32, rest: String, dq: Bool) -> String:
145 match dq:
146 case True{}:
147 "%22" ++ rest
148 case False{}:
149 form.one.cr(cp, rest, U32.is_eq(cp, 13))
150
151# one character of a quoted name, followed by `rest`
152def form.one(+cp: U32, rest: String) -> String:
153 form.one.dq(cp, rest, U32.is_eq(cp, 34))
154
155# a name or filename as the inside of its quoted-string, as HTML's form
156# submission writes it: `"`, CR and LF percent-encoded, the rest as itself
157def form.quote(s: String) -> String:
158 match s:
159 case SNil{}:
160 SNil{}
161 case SCon{Chr{+cp}, tail}:
162 form.one(cp, form.quote(tail))
163
164# --- the parts (RFC 7578 §4) ---
165
166# the Content-Disposition line's text up to its name parameter's end
167def form.disposition(name: String) -> String:
168 "Content-Disposition: form-data; name=\"" ++ form.quote(name) ++ "\""
169
170# a part's header lines, each ended by CRLF (RFC 7578 §4.2, §4.4)
171def form.head(p: Part) -> String:
172 match p:
173 case Field{name, value}:
174 form.disposition(name) ++ "\r\n"
175 case File{name, filename, ctype, clash}:
176 form.disposition(name) ++ "; filename=\"" ++ form.quote(filename) ++ "\"\r\n"
177 ++ "Content-Type: " ++ ctype ++ "\r\n"
178
179# a part's content in front of `rest`: a field's value as UTF-8 octets, a
180# file's bytes as they are
181def form.content(p: Part, rest: List<&2, Seg>) -> List<&2, Seg>:
182 match p:
183 case Field{name, value}:
184 Run{octets.onto(value, [])} <> rest
185 case File{name, filename, ctype, clash}:
186 Blob{} <> rest
187
188# one part in front of `rest`: the delimiter line, its head, a blank line,
189# its content and a CRLF
190def form.part(+b: String, +p: Part, rest: List<&2, Seg>) -> List<&2, Seg>:
191 Run{octets.onto("--" ++ b ++ "\r\n" ++ form.head(p) ++ "\r\n", [])}
192 <> form.content(p, Run{octets.onto("\r\n", [])} <> rest)
193
194# every part in order, then the close delimiter (RFC 2046 §5.1.1)
195def form.parts(+b: String, ps: List<&2, Part>) -> List<&2, Seg>:
196 match ps:
197 case []:
198 [Run{octets.onto("--" ++ b ++ "--\r\n", [])}]
199 case p <> t:
200 form.part(b, p, form.parts(b, t))
201
202# --- the checks ---
203
204# whether a part's content holds the delimiter: a field's octets are
205# searched, a file's answer was found in its bytes
206def form.hit(+b: String, p: Part) -> Bool:
207 match p:
208 case Field{name, value}:
209 octets.has(octets.onto(value, []), form.delim(b))
210 case File{name, filename, ctype, clash}:
211 clash
212
213# whether some part's content holds the delimiter
214def form.clash(+b: String, ps: List<&2, Part>) -> Bool:
215 match ps:
216 case []:
217 False{}
218 case p <> t:
219 any.step(form.hit(b, p), _u => form.clash(b, t))
220
221# whether a part's content type holds a CR or an LF
222def form.badtype.one(p: Part) -> Bool:
223 match p:
224 case Field{name, value}:
225 False{}
226 case File{name, filename, +ctype, clash}:
227 Bool.or(String.contains(ctype, "\r"), String.contains(ctype, "\n"))
228
229# whether any part's content type holds a CR or an LF
230def form.badtype(ps: List<&2, Part>) -> Bool:
231 match ps:
232 case []:
233 False{}
234 case p <> t:
235 any.step(form.badtype.one(p), _u => form.badtype(t))
236
237# the Content-Type field value of a form with this boundary (RFC 7578 §4.1)
238def form.type(b: String) -> String:
239 "multipart/form-data; boundary=" ++ b
240
241# the plan once its content types are checked
242def form.check.type(+b: String, ps: List<&2, Part>, bad: Bool) -> Plan:
243 match bad:
244 case True{}:
245 NoPlan{"a file part's content type holds a CR or LF"}
246 case False{}:
247 Plan{form.type(b), form.parts(b, ps)}
248
249# the plan once its parts are checked against the delimiter
250def form.check.clash(+b: String, +ps: List<&2, Part>, hit: Bool) -> Plan:
251 match hit:
252 case True{}:
253 NoPlan{"the boundary occurs in a part's content"}
254 case False{}:
255 form.check.type(b, ps, form.badtype(ps))
256
257# the plan once its boundary is checked
258def form.check(+b: String, +ps: List<&2, Part>, ok: Bool) -> Plan:
259 match ok:
260 case False{}:
261 NoPlan{"the boundary must be 1 to 70 of A-Z a-z 0-9 - _ ."}
262 case True{}:
263 form.check.clash(b, ps, form.clash(b, ps))
264
265# a multipart/form-data body's plan with this boundary and these parts, or
266# why not (SPEC.md HTTP-FORM)
267def form.plan(+b: String, ps: List<&2, Part>) -> Plan:
268 form.check(b, ps, form.boundary.ok(b))
269
270# --- the body (SPEC.md HTTP-TRUST-8) ---
271
272# whether a search found the delimiter, with the bytes handed back
273def form.found(r: Bytes.Bytes & Maybe<&2, U32>) -> Bytes.Bytes & Bool:
274 (bs, m) = r
275 match m:
276 case None{}:
277 (bs, False{})
278 case Some{i}:
279 (bs, True{})
280
281# a file part once its bytes are searched: the part the plan sees, onto the
282# rest, and its bytes kept in order
283def form.split.file(name: String, filename: String, ctype: String,
284 r: Bytes.Bytes & Bool, rest: List<&2, Part> & List<&1, Bytes.Bytes>)
285 -> List<&2, Part> & List<&1, Bytes.Bytes>:
286 (bs, hit) = r
287 (ps, fs) = rest
288 (File{name, filename, ctype, hit} <> ps, bs <> fs)
289
290# a field part onto the rest
291def form.split.field(name: String, value: String,
292 rest: List<&2, Part> & List<&1, Bytes.Bytes>)
293 -> List<&2, Part> & List<&1, Bytes.Bytes>:
294 (ps, fs) = rest
295 (Field{name, value} <> ps, fs)
296
297# the parts as the plan sees them, and the files' bytes in order; `d` is
298# the delimiter's octets, one character each
299def form.split(+d: String, us: List<&1, Upload>)
300 -> List<&2, Part> & List<&1, Bytes.Bytes>:
301 match us:
302 case []:
303 ([], [])
304 case u <> t:
305 match u:
306 case UField{name, value}:
307 form.split.field(name, value, form.split(d, t))
308 case UFile{name, filename, ctype, bytes}:
309 form.split.file(name, filename, ctype,
310 form.found(Bytes.find(bytes, d)), form.split(d, t))
311
312# the body's runs as packed octets, each file's bytes in its place
313def form.runs(segs: List<&2, Seg>, fs: List<&1, Bytes.Bytes>)
314 -> List<&1, Bytes.Bytes>:
315 match segs:
316 case []:
317 []
318 case Run{octets} <> t:
319 Bytes.from_string(Body.octets.chars(octets)) <> form.runs(t, fs)
320 case Blob{} <> t:
321 match fs:
322 case []:
323 form.runs(t, [])
324 case f <> rest:
325 f <> form.runs(t, rest)
326
327# a plan as the form it builds
328def form.build(p: Plan, fs: List<&1, Bytes.Bytes>) -> Form:
329 match p:
330 case NoPlan{why}:
331 NoForm{why}
332 case Plan{ctype, segs}:
333 Form{ctype, Body.Octets{Bytes.concat(form.runs(segs, fs))}}
334
335# a planned form once the files are searched
336def form.built(+b: String, r: List<&2, Part> & List<&1, Bytes.Bytes>) -> Form:
337 (ps, fs) = r
338 form.build(form.plan(b, ps), fs)
339
340# a multipart/form-data body with this boundary and these parts, or why not
341# (SPEC.md HTTP-FORM, HTTP-TRUST-8)
342def form.body(+b: String, us: List<&1, Upload>) -> Form:
343 form.built(b, form.split(Body.octets.chars(form.delim(b)), us))