src/form.bend

emerging-ezhttp@0.21.0.0 · 343 lines · 43 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.21.0.0/src/body.bend as Body
12import emerging-ezhttp@0.21.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))