src/form.bend

emerging-ezhttp@0.14.0.0 · 239 lines · 0 definitions

1# ezhttp/src/form: multipart/form-data request bodies (RFC 7578), pure. A
2# form is text fields and file parts; the body is octets (Body.Octets), so a
3# file's bytes reach the wire unchanged (SPEC.md HTTP-CLI-10). The caller picks
4# the boundary and the builder refuses one a reader could misread. SPEC.md
5# HTTP-FORM lists what is guaranteed.
6import Base
7import emerging-ezhttp@0.14.0.0/src/body.bend as Body
8import emerging-ezhttp@0.14.0.0/src/pct.bend as Pct
9
10# one part of a form: a text field, or a file with its name and content type
11type Part is Data:
12  Field{name: String, value: String}
13  File{name: String, filename: String, ctype: String, bytes: List<&2, U32>}
14
15# a built form (the Content-Type field value and the body), or why not
16type Form is Data:
17  NoForm{why: String}
18  Form{ctype: String, body: Body.Body}
19
20# a text field
21def form.field(name: String, value: String) -> Part:
22  Field{name, value}
23
24# a file part: its field name, its filename, its content type and its bytes
25# (each 0..255)
26def form.file(name: String, filename: String, ctype: String,
27  bytes: List<&2, U32>) -> Part:
28  File{name, filename, ctype, bytes}
29
30# --- octets ---
31
32# a string's UTF-8 octets in front of `rest` (RFC 3629 §3)
33def octets.onto(s: String, rest: List<&2, U32>) -> List<&2, U32>:
34  match s:
35    case SNil{}:
36      rest
37    case SCon{Chr{+cp}, tail}:
38      List.append(&2, U32, Pct.utf8(cp), octets.onto(tail, rest))
39
40# a check that holds when this does and the rest does: a false ends it
41def all.step(ok: Bool, rest: Unit -> Bool) -> Bool:
42  match ok:
43    case False{}:
44      False{}
45    case True{}:
46      rest(Unit{})
47
48# a search that hits when this does or the rest does: a true ends it
49def any.step(hit: Bool, rest: Unit -> Bool) -> Bool:
50  match hit:
51    case True{}:
52      True{}
53    case False{}:
54      rest(Unit{})
55
56# whether octets open with a pattern's octets
57def octets.starts(xs: List<&2, U32>, pat: List<&2, U32>) -> Bool:
58  match xs pat:
59    case Nil{} Nil{}:
60      True{}
61    case Nil{} Con{p, q}:
62      False{}
63    case Con{x, t} Nil{}:
64      True{}
65    case Con{x, t} Con{p, q}:
66      all.step(U32.is_eq(x, p), _u => octets.starts(t, q))
67
68# whether a pattern's octets occur as a contiguous run anywhere in octets
69def octets.has(xs: List<&2, U32>, +pat: List<&2, U32>) -> Bool:
70  match xs:
71    case []:
72      octets.starts([], pat)
73    case +x <> +t:
74      any.step(octets.starts(x <> t, pat), _u => octets.has(t, pat))
75
76# --- the boundary (RFC 2046 §5.1.1) ---
77
78# a boundary character this builder allows: A-Z a-z 0-9 - _ . (each a bchar
79# of RFC 2046 and a tchar of RFC 9110 §5.6.2, so no quotes are needed)
80def form.bchar(+cp: U32) -> Bool:
81  Bool.or(Bool.and(U32.is_le(65, cp), U32.is_le(cp, 90)),
82    Bool.or(Bool.and(U32.is_le(97, cp), U32.is_le(cp, 122)),
83      Bool.or(Bool.and(U32.is_le(48, cp), U32.is_le(cp, 57)),
84        Bool.or(U32.is_eq(cp, 45),
85          Bool.or(U32.is_eq(cp, 95), U32.is_eq(cp, 46))))))
86
87# every character of a string is a boundary character
88def form.bchars(s: String) -> Bool:
89  match s:
90    case SNil{}:
91      True{}
92    case SCon{Chr{+cp}, tail}:
93      all.step(form.bchar(cp), _u => form.bchars(tail))
94
95# a boundary is 1 to 70 boundary characters
96def form.boundary.ok(+b: String) -> Bool:
97  Bool.and(Bool.and(Bool.not(String.is_empty(b)),
98    Nat.is_le(String.length(b), 70n)), form.bchars(b))
99
100# the delimiter a reader looks for: "--" and the boundary, as octets
101def form.delim(b: String) -> List<&2, U32>:
102  octets.onto("--" ++ b, [])
103
104# --- quoting (RFC 7578 §4.2) ---
105
106# a character that is not `"` or CR: LF as %0A, anything else as itself
107def form.one.lf(+cp: U32, rest: String, lf: Bool) -> String:
108  match lf:
109    case True{}:
110      "%0A" ++ rest
111    case False{}:
112      SCon{Chr{cp}, rest}
113
114# a character that is not `"`: CR as %0D, else on to LF
115def form.one.cr(+cp: U32, rest: String, cr: Bool) -> String:
116  match cr:
117    case True{}:
118      "%0D" ++ rest
119    case False{}:
120      form.one.lf(cp, rest, U32.is_eq(cp, 10))
121
122# a character of a quoted name, once it is known whether it is `"`: as %22
123def form.one.dq(+cp: U32, rest: String, dq: Bool) -> String:
124  match dq:
125    case True{}:
126      "%22" ++ rest
127    case False{}:
128      form.one.cr(cp, rest, U32.is_eq(cp, 13))
129
130# one character of a quoted name, followed by `rest`
131def form.one(+cp: U32, rest: String) -> String:
132  form.one.dq(cp, rest, U32.is_eq(cp, 34))
133
134# a name or filename as the inside of its quoted-string, as HTML's form
135# submission writes it: `"`, CR and LF percent-encoded, the rest as itself
136def form.quote(s: String) -> String:
137  match s:
138    case SNil{}:
139      SNil{}
140    case SCon{Chr{+cp}, tail}:
141      form.one(cp, form.quote(tail))
142
143# --- the parts (RFC 7578 §4) ---
144
145# the Content-Disposition line's text up to its name parameter's end
146def form.disposition(name: String) -> String:
147  "Content-Disposition: form-data; name=\"" ++ form.quote(name) ++ "\""
148
149# a part's header lines, each ended by CRLF (RFC 7578 §4.2, §4.4)
150def form.head(p: Part) -> String:
151  match p:
152    case Field{name, value}:
153      form.disposition(name) ++ "\r\n"
154    case File{name, filename, ctype, bytes}:
155      form.disposition(name) ++ "; filename=\"" ++ form.quote(filename) ++ "\"\r\n"
156        ++ "Content-Type: " ++ ctype ++ "\r\n"
157
158# a part's content in front of `rest`: a field's value as UTF-8, a file's
159# bytes as they are
160def form.content(p: Part, rest: List<&2, U32>) -> List<&2, U32>:
161  match p:
162    case Field{name, value}:
163      octets.onto(value, rest)
164    case File{name, filename, ctype, bytes}:
165      List.append(&2, U32, bytes, rest)
166
167# one part in front of `rest`: the delimiter line, its head, a blank line,
168# its content and a CRLF
169def form.part(+b: String, +p: Part, rest: List<&2, U32>) -> List<&2, U32>:
170  octets.onto("--" ++ b ++ "\r\n" ++ form.head(p) ++ "\r\n",
171    form.content(p, octets.onto("\r\n", rest)))
172
173# every part in order, then the close delimiter (RFC 2046 §5.1.1)
174def form.parts(+b: String, ps: List<&2, Part>) -> List<&2, U32>:
175  match ps:
176    case []:
177      octets.onto("--" ++ b ++ "--\r\n", [])
178    case p <> t:
179      form.part(b, p, form.parts(b, t))
180
181# --- the checks ---
182
183# whether some part's content holds the delimiter
184def form.clash(+b: String, ps: List<&2, Part>) -> Bool:
185  match ps:
186    case []:
187      False{}
188    case p <> t:
189      any.step(octets.has(form.content(p, []), form.delim(b)),
190        _u => form.clash(b, t))
191
192# whether a part's content type holds a CR or an LF
193def form.badtype.one(p: Part) -> Bool:
194  match p:
195    case Field{name, value}:
196      False{}
197    case File{name, filename, +ctype, bytes}:
198      Bool.or(String.contains(ctype, "\r"), String.contains(ctype, "\n"))
199
200# whether any part's content type holds a CR or an LF
201def form.badtype(ps: List<&2, Part>) -> Bool:
202  match ps:
203    case []:
204      False{}
205    case p <> t:
206      any.step(form.badtype.one(p), _u => form.badtype(t))
207
208# the Content-Type field value of a form with this boundary (RFC 7578 §4.1)
209def form.type(b: String) -> String:
210  "multipart/form-data; boundary=" ++ b
211
212# the form once its content types are checked
213def form.check.type(+b: String, ps: List<&2, Part>, bad: Bool) -> Form:
214  match bad:
215    case True{}:
216      NoForm{"a file part's content type holds a CR or LF"}
217    case False{}:
218      Form{form.type(b), Body.Octets{form.parts(b, ps)}}
219
220# the form once its parts are checked against the delimiter
221def form.check.clash(+b: String, +ps: List<&2, Part>, hit: Bool) -> Form:
222  match hit:
223    case True{}:
224      NoForm{"the boundary occurs in a part's content"}
225    case False{}:
226      form.check.type(b, ps, form.badtype(ps))
227
228# the form once its boundary is checked
229def form.check(+b: String, +ps: List<&2, Part>, ok: Bool) -> Form:
230  match ok:
231    case False{}:
232      NoForm{"the boundary must be 1 to 70 of A-Z a-z 0-9 - _ ."}
233    case True{}:
234      form.check.clash(b, ps, form.clash(b, ps))
235
236# a multipart/form-data body with this boundary and these parts, or why not
237# (SPEC.md HTTP-FORM)
238def form.body(+b: String, ps: List<&2, Part>) -> Form:
239  form.check(b, ps, form.boundary.ok(b))