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. 9 import Base 10 import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes 11 import ./body.bend as Body 12 import ./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 16 type 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 22 type 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 27 type 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 33 type 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 38 type 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 43 def 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 47 def 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) 54 def 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 62 def 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 70 def 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 78 def 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 90 def 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) 101 def 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 109 def 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 117 def 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 122 def 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 128 def 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 136 def 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 144 def 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` 152 def 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 157 def 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 167 def 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) 171 def 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 181 def 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 190 def 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) 195 def 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 206 def 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 214 def 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 222 def 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 230 def 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) 238 def form.type(b: String) -> String: 239 "multipart/form-data; boundary=" ++ b 240 241 # the plan once its content types are checked 242 def 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 250 def 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 258 def 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) 267 def 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 273 def 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 283 def 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 291 def 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 299 def 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 313 def 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 328 def 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 336 def 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) 342 def form.body(+b: String, us: List<&1, Upload>) -> Form: 343 form.built(b, form.split(Body.octets.chars(form.delim(b)), us))