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. 6 import Base 7 import ./body.bend as Body 8 import ./pct.bend as Pct 9 10 # one part of a form: a text field, or a file with its name and content type 11 type 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 16 type Form is Data: 17 NoForm{why: String} 18 Form{ctype: String, body: Body.Body} 19 20 # a text field 21 def 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) 26 def 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) 33 def 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 41 def 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 49 def 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 57 def 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 69 def 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) 80 def 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 88 def 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 96 def 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 101 def 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 107 def 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 115 def 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 123 def 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` 131 def 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 136 def 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 146 def 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) 150 def 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 160 def 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 169 def 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) 174 def 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 184 def 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 193 def 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 201 def 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) 209 def form.type(b: String) -> String: 210 "multipart/form-data; boundary=" ++ b 211 212 # the form once its content types are checked 213 def 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 221 def 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 229 def 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) 238 def form.body(+b: String, ps: List<&2, Part>) -> Form: 239 form.check(b, ps, form.boundary.ok(b))