Skip to content
heff

std.multipart

Multipart bodies (RFC 2046 §5.1), and forms sent as one, form-data (RFC 7578): a body read into its parts (parse) and written of them (render), each part its headers and its body. The boundary is a Content-Type’s (boundaryOf); a part’s form name and file name are its Content-Disposition’s (formName, fileName). Go’s mime/multipart is the model: its NextRawPart, as a body is kept as it is written, whatever its Content-Transfer-Encoding. Import it by a name, import std.multipart as Multipart.

import bytes as Bytes
import std.multipart as Multipart
-- | >>> fields "multipart/form-data; boundary=X" (Bytes.fromString "--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--X--\r\n")
-- | [MkPair "a" "1"]
fields : String -> Bytes -> List (String, String)
fields contentType body = case Multipart.boundaryOf contentType of
Nothing -> Nil
Just b -> case Multipart.parse body (boundary := b) of
Left e -> Nil
Right parts ->
List.mapMaybe
(\(p : Multipart.Part) ->
maybe Nothing (\n -> Just (n, Bytes.asString p.body)) (Multipart.formName p))
parts

Part

type Part = { headers : List (String, String), body : Bytes }

A part: its headers, a name and a value each, in their order and as they are written (a header is looked for by its name, its case not minded), and its body.

ParseError

type ParseError = { offset : Int, message : String }

Where a body is not a multipart one: its byte there, and what is wrong.

boundaryOf

boundaryOf : String -> Maybe String

The boundary of a Content-Type of a multipart body (multipart/ and any subtype), as std.mime reads it, if it has one, not empty.

>>> boundaryOf "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
Just "----WebKitFormBoundary7MA4YWxkTrZu0gW"
>>> boundaryOf "Multipart/Mixed; boundary=\"simple boundary\""
Just "simple boundary"
>>> List.map boundaryOf ["text/plain; boundary=x", "multipart/mixed", "multipart/mixed; boundary=\"\"", "multipart/mixed; boundary="]
[Nothing, Nothing, Nothing, Nothing]

parse

parse : Bytes -> (boundary : String) -> Either ParseError (List Part)

The parts of a multipart body of the boundary, as Go’s Reader reads them: its preamble, before the first delimiter, and its epilogue, after the close delimiter, not read; each part after a delimiter line, -- and the boundary, its header lines up to an empty one, and its body up to the line break before the next delimiter. A delimiter line may have spaces and tabs before its line break; the close delimiter, -- after it, ends at a line break or the end. Lines end with a carriage return and a line feed, or a line feed alone, as the first delimiter line ends (in the headers, either); a header line beginning with a space or a tab goes on the one before it, the two joined by a space.

>>> parse (Bytes.fromString "--AaB03x\r\ncontent-disposition: form-data; name=\"field1\"\r\n\r\nJoe Blow\r\n--AaB03x\r\ncontent-disposition: form-data; name=\"pics\"; filename=\"file1.txt\"\r\nContent-Type: text/plain\r\n\r\n ... contents of file1.txt ...\r\n--AaB03x--\r\n") (boundary := "AaB03x")
Right [{ headers = [MkPair "content-disposition" "form-data; name=\"field1\""], body = b["Joe Blow"] }, { headers = [MkPair "content-disposition" "form-data; name=\"pics\"; filename=\"file1.txt\"", MkPair "Content-Type" "text/plain"], body = b[" ... contents of file1.txt ..."] }]

(RFC 2046’s example: a preamble, a part of no headers, a body ended by a line break, an epilogue)

>>> parse (Bytes.fromString "This is the preamble.\r\n--simple boundary\r\n\r\nThis is implicitly typed plain US-ASCII text.\r\n--simple boundary\r\nContent-type: text/plain; charset=us-ascii\r\n\r\nThis DOES end with a linebreak.\r\n\r\n--simple boundary--\r\n\r\nThis is the epilogue.\r\n") (boundary := "simple boundary")
Right [{ headers = [], body = b["This is implicitly typed plain US-ASCII text."] }, { headers = [MkPair "Content-type" "text/plain; charset=us-ascii"], body = b["This DOES end with a linebreak.", 13, 10] }]

(line feeds alone; spaces after a delimiter; a header line folded; an empty body, and one where a delimiter line would follow at once; a boundary’s text in a body, not after a line break or before another byte; no parts)

>>> parse (Bytes.fromString "--b \t\nA: 1\n and 2\n\nx\n--b\nB:2\r\n\r\n\n--b--") (boundary := "b")
Right [{ headers = [MkPair "A" "1 and 2"], body = b["x"] }, { headers = [MkPair "B" "2"], body = b[] }]
>>> List.map (\(r : Either ParseError (List Part)) -> either (\_ -> Nil) (List.map (\(p : Part) -> Bytes.asString p.body)) r) [parse (Bytes.fromString "--b\r\n\r\n--b\r\n\r\n--b--") (boundary := "b"), parse (Bytes.fromString "--b\r\n\r\nx--b\r\n--bc\r\n--b-\r\n--b--") (boundary := "b"), parse (Bytes.fromString "preamble\r\n--b--\r\n") (boundary := "b")]
[["", ""], ["x--b\r\n--bc\r\n--b-"], []]

(where it is not one: no delimiter, no close delimiter, a header line not one, a delimiter line with more after it)

>>> parse (Bytes.fromString "no parts here\r\n") (boundary := "b")
Left { offset = 15, message = "missing close delimiter" }
>>> parse (Bytes.fromString "--b\r\n\r\nbody\r\n--b\r\n") (boundary := "b")
Left { offset = 18, message = "missing close delimiter" }
>>> parse (Bytes.fromString "--b\r\nA: 1\r\nno colon\r\n\r\n\r\n--b--") (boundary := "b")
Left { offset = 11, message = "malformed MIME header line" }
>>> List.map (\s -> parse (Bytes.fromString s) (boundary := "b")) ["--b\r\n A: 1\r\n\r\n\r\n--b--", "--b\r\nA B: 1\r\n\r\n\r\n--b--"]
[Left { offset = 5, message = "malformed MIME header initial line" }, Left { offset = 5, message = "malformed MIME header line" }]
>>> List.map (\s -> parse (Bytes.fromString s) (boundary := "b")) ["--b\r\n\r\nx\r\n--b x\r\n", "--b\r\n\r\nx\r\n--b--x", "--b\n\nx\n--b\r\n\n--b--"]
[Left { offset = 14, message = "malformed delimiter line" }, Left { offset = 15, message = "malformed delimiter line" }, Left { offset = 10, message = "malformed delimiter line" }]

render

render : List Part -> (boundary : String) -> Bytes

A multipart body of the parts and the boundary, as Go’s Writer writes one: each part after a delimiter line, -- and the boundary, its headers in their order, Name: value, an empty line and its body, a line break after it; then the close delimiter, -- after the boundary, and a line break — each line break a carriage return and a line feed. A header’s value is written as it is: one with a line break in it, or a body with a delimiter line, does not read back.

>>> Bytes.asString (render [{ headers = [("Content-Disposition", "form-data; name=\"a\"")], body = Bytes.fromString "1" }, { headers = Nil, body = Bytes.empty }] (boundary := "X"))
"--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--X\r\n\r\n\r\n--X--\r\n"
>>> Bytes.asString (render Nil (boundary := "X"))
"--X--\r\n"

(what it writes, parse reads back)

>>> parse (render [{ headers = [("Content-Disposition", "form-data; name=\"f\"; filename=\"a.bin\""), ("Content-Type", "application/octet-stream")], body = Bytes.fromList [0, 13, 10, 45, 45, 255] }, { headers = [("X", "")], body = Bytes.fromString "\r\n" }] (boundary := "b0und")) (boundary := "b0und")
Right [{ headers = [MkPair "Content-Disposition" "form-data; name=\"f\"; filename=\"a.bin\"", MkPair "Content-Type" "application/octet-stream"], body = b[0, 13, 10, "--", 255] }, { headers = [MkPair "X" ""], body = b[13, 10] }]

formName

formName : Part -> Maybe String

The name of a part of a form, its Content-Disposition’s name, where it is form-data.

>>> formName { headers = [("content-disposition", "form-data; name=\"user\"")], body = Bytes.empty }
Just "user"
>>> List.map (\h -> formName { headers = [h], body = Bytes.empty }) [("Content-Disposition", "attachment; name=x"), ("Content-Disposition", "form-data"), ("Content-Type", "form-data; name=x")]
[Nothing, Nothing, Nothing]

fileName

fileName : Part -> Maybe String

The file name of a part, its Content-Disposition’s filename, not empty, and of it only what is after its last /, as RFC 7578 §4.2 asks and Go’s FileName takes it (by filepath.Base on a Unix: a \ kept, a / at the end not minded).

>>> fileName { headers = [("Content-Disposition", "form-data; name=\"f\"; filename=\"file1.txt\"")], body = Bytes.empty }
Just "file1.txt"
>>> List.map (\d -> fileName { headers = [("Content-Disposition", d)], body = Bytes.empty }) ["attachment; filename=\"../../etc/passwd\"", "form-data; filename=\"dir/\"", "form-data; filename*=utf-8''%E2%82%AC.txt", "form-data; filename=\"C:\\dev\\a.txt\"", "form-data; filename=\"\"", "form-data; name=f"]
[Just "passwd", Just "dir", Just "\u{20ac}.txt", Just "C:\\dev\\a.txt", Nothing, Nothing]