src/form.bend
emerging-ezhttp@0.15.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.15.0.0/src/body.bend as Body
8import emerging-ezhttp@0.15.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))