///|
/// A parsed `Content-Type` or part-level media type value.
pub(all) struct MediaType {
typ : String
subtype : String
params : Array[(String, String)]
} derive(Debug, Eq)
///|
/// Parsed `Content-Disposition` metadata for one multipart part.
pub(all) struct ContentDisposition {
disposition : String
params : Array[(String, String)]
} derive(Debug, Eq)
///|
/// A single multipart part. File uploads have a `filename`; ordinary form
/// fields do not.
pub(all) struct FormPart {
name : String
filename : String?
content_type : String?
headers : Array[(String, String)]
body : String
} derive(Debug, Eq)
///|
/// Parsed form data. `parts` preserves wire order, including repeated fields.
pub(all) struct MultipartForm {
boundary : String
parts : Array[FormPart]
} derive(Debug, Eq)
///|
/// Parser limits for the in-memory v1 implementation.
pub(all) struct ParseOptions {
max_parts : Int
max_headers_per_part : Int
max_body_length : Int
} derive(Debug, Eq)
///|
/// Result of `encode_multipart`.
pub(all) struct EncodedForm {
content_type : String
body : String
boundary : String
} derive(Debug, Eq)
///|
/// Structured errors make validation failures explainable in examples and CI.
pub(all) enum MultipartError {
MissingBoundary
InvalidBoundary(String)
InvalidHeader(String)
InvalidContentType(String)
InvalidContentDisposition(String)
MalformedBody(String)
LimitExceeded(String)
} derive(Debug, Eq)
///|
/// Human-readable error message for logs, CLI examples, and HTTP 400 replies.
pub fn MultipartError::message(self : MultipartError) -> String {
match self {
MissingBoundary => "missing multipart boundary"
InvalidBoundary(value) =>
"invalid multipart boundary: " + display_snippet(value)
InvalidHeader(value) =>
"invalid multipart header: " + display_snippet(value)
InvalidContentType(value) =>
"invalid Content-Type: " + display_snippet(value)
InvalidContentDisposition(value) =>
"invalid Content-Disposition: " + display_snippet(value)
MalformedBody(value) =>
"malformed multipart body: " + display_snippet(value)
LimitExceeded(value) =>
"multipart parser limit exceeded: " + display_snippet(value)
}
}
///|
/// Conservative defaults for small and medium form payloads.
pub fn default_parse_options() -> ParseOptions {
{ max_parts: 128, max_headers_per_part: 32, max_body_length: 1024 * 1024 * 4 }
}
///|
/// Parse `Content-Type` and extract the `boundary` parameter.
pub fn boundary_from_content_type(
header : String,
) -> Result[String, MultipartError] {
match MediaType::parse(header) {
Ok(media) =>
if media.typ != "multipart" || media.subtype != "form-data" {
Err(InvalidContentType(header))
} else {
match media.param("boundary") {
Some(boundary) => validate_boundary(boundary)
None => Err(MissingBoundary)
}
}
Err(err) => Err(err)
}
}
///|
/// Parse a media type such as `multipart/form-data; boundary=abc`.
pub fn MediaType::parse(header : String) -> Result[MediaType, MultipartError] {
let pieces = split_header_parameters(header)
if pieces.length() == 0 {
return Err(InvalidContentType(header))
}
let main = trim_ascii(pieces[0])
match main.split_once("/") {
Some((typ_view, subtype_view)) => {
let typ = to_lower_ascii(trim_ascii(typ_view.to_owned()))
let subtype = to_lower_ascii(trim_ascii(subtype_view.to_owned()))
if typ == "" || subtype == "" {
return Err(InvalidContentType(header))
}
let params = Array::new()
let mut i = 1
while i < pieces.length() {
match parse_parameter(pieces[i]) {
Ok((name, value)) => params.push((to_lower_ascii(name), value))
Err(_) => return Err(InvalidContentType(header))
}
i = i + 1
}
Ok({ typ, subtype, params })
}
None => Err(InvalidContentType(header))
}
}
///|
/// Return a parameter by ASCII case-insensitive name.
pub fn MediaType::param(self : MediaType, name : String) -> String? {
find_param(self.params, name)
}
///|
/// Parse a `Content-Disposition` header.
pub fn ContentDisposition::parse(
header : String,
) -> Result[ContentDisposition, MultipartError] {
let pieces = split_header_parameters(header)
if pieces.length() == 0 {
return Err(InvalidContentDisposition(header))
}
let disposition = to_lower_ascii(trim_ascii(pieces[0]))
if disposition == "" {
return Err(InvalidContentDisposition(header))
}
let params = Array::new()
let mut i = 1
while i < pieces.length() {
match parse_parameter(pieces[i]) {
Ok((name, value)) => params.push((to_lower_ascii(name), value))
Err(_) => return Err(InvalidContentDisposition(header))
}
i = i + 1
}
Ok({ disposition, params })
}
///|
/// Return a disposition parameter by ASCII case-insensitive name.
pub fn ContentDisposition::param(
self : ContentDisposition,
name : String,
) -> String? {
find_param(self.params, name)
}
///|
/// Parse an in-memory multipart body with conservative default limits.
pub fn parse_multipart(
body : String,
boundary : String,
) -> Result[MultipartForm, MultipartError] {
parse_multipart_with_options(body, boundary, default_parse_options())
}
///|
/// Parse an in-memory multipart body with caller-provided limits.
pub fn parse_multipart_with_options(
body : String,
boundary : String,
options : ParseOptions,
) -> Result[MultipartForm, MultipartError] {
let checked_boundary = match validate_boundary(boundary) {
Ok(value) => value
Err(err) => return Err(err)
}
if body.length() > options.max_body_length {
return Err(
LimitExceeded("body length exceeds ParseOptions.max_body_length"),
)
}
let delimiter = "--" + checked_boundary
let first = body.find(delimiter)
match first {
None => Err(MalformedBody("opening boundary not found"))
Some(start) => {
if start != 0 &&
slice_string(body, 0, start) != "\r\n" &&
slice_string(body, 0, start) != "\n" {
return Err(MalformedBody("unexpected preamble before first boundary"))
}
parse_sections(slice_from(body, start), checked_boundary, options)
}
}
}
///|
/// Build a multipart body. The returned `content_type` is ready for HTTP use.
pub fn encode_multipart(
parts : Array[FormPart],
boundary? : String,
) -> Result[EncodedForm, MultipartError] {
let candidate = match boundary {
Some(value) => value
None => "MoonFormDataBoundary0000000000000001"
}
let checked_boundary = match validate_boundary(candidate) {
Ok(value) => value
Err(err) => return Err(err)
}
if parts.length() == 0 {
return Err(MalformedBody("cannot encode an empty multipart form"))
}
let out = Array::new()
let mut i = 0
while i < parts.length() {
let part = parts[i]
if part.name == "" {
return Err(InvalidContentDisposition("part name must not be empty"))
}
let filename_segment = match part.filename {
Some(filename) =>
"; filename=\"" + escape_quoted(safe_filename(filename)) + "\""
None => ""
}
out.push("--" + checked_boundary + "\r\n")
out.push(
"Content-Disposition: form-data; name=\"" +
escape_quoted(part.name) +
"\"" +
filename_segment +
"\r\n",
)
match part.content_type {
Some(content_type) =>
if content_type != "" {
out.push("Content-Type: " + content_type + "\r\n")
}
None => ()
}
let mut h = 0
while h < part.headers.length() {
let (name, value) = part.headers[h]
if !is_safe_header_name(name) {
return Err(InvalidHeader(name))
}
if name.compare_ignore_ascii_case("Content-Disposition") != 0 &&
name.compare_ignore_ascii_case("Content-Type") != 0 {
out.push(name + ": " + strip_header_linebreaks(value) + "\r\n")
}
h = h + 1
}
out.push("\r\n")
out.push(part.body)
out.push("\r\n")
i = i + 1
}
out.push("--" + checked_boundary + "--\r\n")
Ok({
content_type: "multipart/form-data; boundary=" + checked_boundary,
body: out.join(""),
boundary: checked_boundary,
})
}
///|
/// Return all values for an ordinary form field.
pub fn MultipartForm::field_values(
self : MultipartForm,
name : String,
) -> Array[String] {
let values = Array::new()
let mut i = 0
while i < self.parts.length() {
let part = self.parts[i]
if part.name == name && part.filename is None {
values.push(part.body)
}
i = i + 1
}
values
}
///|
/// Return the first ordinary form field value.
pub fn MultipartForm::field_value(
self : MultipartForm,
name : String,
) -> String? {
let mut i = 0
while i < self.parts.length() {
let part = self.parts[i]
if part.name == name && part.filename is None {
return Some(part.body)
}
i = i + 1
}
None
}
///|
/// Return file parts for a given field name.
pub fn MultipartForm::files(
self : MultipartForm,
name : String,
) -> Array[FormPart] {
let files = Array::new()
let mut i = 0
while i < self.parts.length() {
let part = self.parts[i]
if part.name == name && part.filename is Some(_) {
files.push(part)
}
i = i + 1
}
files
}
///|
/// Remove path separators, drive prefixes, and control characters from a client
/// supplied filename. Returns `"upload.bin"` when no safe character remains.
pub fn safe_filename(filename : String) -> String {
let normalized = filename.replace_all(old="\\", new="/")
let mut base = normalized
match normalized.rev_split_once("/") {
Some((_, tail)) => base = tail.to_owned()
None => ()
}
if base.length() >= 2 && base[1] == 58 {
base = slice_from(base, 2)
}
let chars = Array::new()
let mut i = 0
while i < base.length() {
let code = base[i]
if code >= 32 && code != 47 && code != 92 && code != 58 {
chars.push(char_at(base, i))
}
i = i + 1
}
let cleaned = trim_ascii(String::from_array(chars))
if cleaned == "" || cleaned == "." || cleaned == ".." {
"upload.bin"
} else {
cleaned
}
}
///|
fn parse_sections(
source : String,
boundary : String,
options : ParseOptions,
) -> Result[MultipartForm, MultipartError] {
let marker = "--" + boundary
let sections = source.split(marker).collect()
let parts = Array::new()
let mut closed = false
let mut i = 1
while i < sections.length() {
let raw = sections[i].to_owned()
if raw.has_prefix("--") {
let tail = slice_from(raw, 2)
if tail == "" || tail == "\r\n" || tail == "\n" {
closed = true
break
}
return Err(MalformedBody("unexpected data after closing boundary"))
}
if raw == "" {
return Err(MalformedBody("empty boundary section"))
}
let section = strip_one_leading_newline(raw)
if section != "" {
let part = match parse_part(section, options) {
Ok(value) => value
Err(err) => return Err(err)
}
parts.push(part)
if parts.length() > options.max_parts {
return Err(LimitExceeded("part count exceeds ParseOptions.max_parts"))
}
}
i = i + 1
}
if !closed {
Err(MalformedBody("closing boundary not found"))
} else {
Ok({ boundary, parts })
}
}
///|
fn parse_part(
section : String,
options : ParseOptions,
) -> Result[FormPart, MultipartError] {
let split = match section.find("\r\n\r\n") {
Some(idx) => Some((idx, 4))
None =>
match section.find("\n\n") {
Some(idx) => Some((idx, 2))
None => None
}
}
match split {
None => Err(MalformedBody("part has no header/body separator"))
Some((idx, sep_len)) => {
let header_block = slice_string(section, 0, idx)
let mut body = slice_from(section, idx + sep_len)
body = strip_one_trailing_newline(body)
let headers = match
parse_headers(header_block, options.max_headers_per_part) {
Ok(value) => value
Err(err) => return Err(err)
}
let disposition_header = match
header_value(headers, "Content-Disposition") {
Some(value) => value
None =>
return Err(InvalidContentDisposition("missing Content-Disposition"))
}
let disposition = match ContentDisposition::parse(disposition_header) {
Ok(value) => value
Err(err) => return Err(err)
}
if disposition.disposition != "form-data" {
return Err(InvalidContentDisposition(disposition_header))
}
let name = match disposition.param("name") {
Some(value) =>
if value == "" {
return Err(InvalidContentDisposition("empty part name"))
} else {
value
}
None => return Err(InvalidContentDisposition("missing part name"))
}
let filename = filename_from_disposition(disposition)
Ok({
name,
filename,
content_type: header_value(headers, "Content-Type"),
headers,
body,
})
}
}
}
///|
fn parse_headers(
block : String,
max_headers : Int,
) -> Result[Array[(String, String)], MultipartError] {
let headers = Array::new()
let lines = block.replace_all(old="\r\n", new="\n").split("\n").collect()
let mut i = 0
while i < lines.length() {
let line = lines[i].to_owned()
if trim_ascii(line) != "" {
match line.split_once(":") {
Some((name_view, value_view)) => {
let name = trim_ascii(name_view.to_owned())
let value = trim_ascii(value_view.to_owned())
if !is_safe_header_name(name) {
return Err(InvalidHeader(line))
}
headers.push((name, value))
if headers.length() > max_headers {
return Err(
LimitExceeded(
"header count exceeds ParseOptions.max_headers_per_part",
),
)
}
}
None => return Err(InvalidHeader(line))
}
}
i = i + 1
}
Ok(headers)
}
///|
fn header_value(headers : Array[(String, String)], name : String) -> String? {
let mut i = 0
while i < headers.length() {
let (header_name, value) = headers[i]
if header_name.compare_ignore_ascii_case(name) == 0 {
return Some(value)
}
i = i + 1
}
None
}
///|
fn validate_boundary(boundary : String) -> Result[String, MultipartError] {
if boundary == "" || boundary.length() > 70 {
return Err(InvalidBoundary(boundary))
}
let mut i = 0
while i < boundary.length() {
let code = boundary[i]
if !is_boundary_code_unit(code) {
return Err(InvalidBoundary(boundary))
}
i = i + 1
}
Ok(boundary)
}
///|
fn split_header_parameters(header : String) -> Array[String] {
let parts = Array::new()
let current = Array::new()
let mut quoted = false
let mut escaped = false
let mut i = 0
while i < header.length() {
let ch = char_at(header, i)
if escaped {
current.push(ch)
escaped = false
} else if quoted && ch == '\\' {
current.push(ch)
escaped = true
} else if ch == '"' {
current.push(ch)
quoted = !quoted
} else if !quoted && ch == ';' {
parts.push(String::from_array(current))
current.clear()
} else {
current.push(ch)
}
i = i + 1
}
parts.push(String::from_array(current))
parts
}
///|
fn parse_parameter(raw : String) -> Result[(String, String), Unit] {
match raw.split_once("=") {
Some((name_view, value_view)) => {
let name = trim_ascii(name_view.to_owned())
if name == "" || !is_token(name) {
return Err(())
}
let value = parse_parameter_value(trim_ascii(value_view.to_owned()))
Ok((name, value))
}
None => Err(())
}
}
///|
fn parse_parameter_value(value : String) -> String {
if value.length() >= 2 && value[0] == 34 && value[value.length() - 1] == 34 {
unescape_quoted(slice_string(value, 1, value.length() - 1))
} else {
value
}
}
///|
fn find_param(params : Array[(String, String)], name : String) -> String? {
let lower = to_lower_ascii(name)
let mut i = 0
while i < params.length() {
let (key, value) = params[i]
if to_lower_ascii(key) == lower {
return Some(value)
}
i = i + 1
}
None
}
///|
fn strip_one_leading_newline(value : String) -> String {
if value.has_prefix("\r\n") {
slice_from(value, 2)
} else if value.has_prefix("\n") {
slice_from(value, 1)
} else {
value
}
}
///|
fn strip_one_trailing_newline(value : String) -> String {
if value.has_suffix("\r\n") {
slice_string(value, 0, value.length() - 2)
} else if value.has_suffix("\n") {
slice_string(value, 0, value.length() - 1)
} else {
value
}
}
///|
fn trim_ascii(value : String) -> String {
value.trim(chars=" \t\r\n").to_owned()
}
///|
fn slice_string(value : String, start : Int, end : Int) -> String {
value.unsafe_substring(start~, end~)
}
///|
fn slice_from(value : String, start : Int) -> String {
value.unsafe_substring(start~, end=value.length())
}
///|
fn char_at(value : String, index : Int) -> Char {
value.get_char(index).unwrap()
}
///|
fn to_lower_ascii(value : String) -> String {
value.to_lower()
}
///|
fn escape_quoted(value : String) -> String {
value.replace_all(old="\\", new="\\\\").replace_all(old="\"", new="\\\"")
}
///|
fn unescape_quoted(value : String) -> String {
let chars = Array::new()
let mut escaped = false
let mut i = 0
while i < value.length() {
let ch = char_at(value, i)
if escaped {
chars.push(ch)
escaped = false
} else if ch == '\\' {
escaped = true
} else {
chars.push(ch)
}
i = i + 1
}
if escaped {
chars.push('\\')
}
String::from_array(chars)
}
///|
fn strip_header_linebreaks(value : String) -> String {
value.replace_all(old="\r", new="").replace_all(old="\n", new="")
}
///|
fn display_snippet(value : String) -> String {
value
.replace_all(old="\\", new="\\\\")
.replace_all(old="\r", new="\\r")
.replace_all(old="\n", new="\\n")
}
///|
fn is_safe_header_name(name : String) -> Bool {
if name == "" {
return false
}
is_token(name)
}
///|
fn is_token(value : String) -> Bool {
if value == "" {
return false
}
let mut i = 0
while i < value.length() {
if !is_token_code_unit(value[i]) {
return false
}
i = i + 1
}
true
}
///|
fn is_token_code_unit(code : UInt16) -> Bool {
(code >= 97 && code <= 122) ||
(code >= 65 && code <= 90) ||
(code >= 48 && code <= 57) ||
code == 33 ||
code == 35 ||
code == 36 ||
code == 37 ||
code == 38 ||
code == 39 ||
code == 42 ||
code == 43 ||
code == 45 ||
code == 46 ||
code == 94 ||
code == 95 ||
code == 96 ||
code == 124 ||
code == 126
}
///|
fn is_boundary_code_unit(code : UInt16) -> Bool {
(code >= 97 && code <= 122) ||
(code >= 65 && code <= 90) ||
(code >= 48 && code <= 57) ||
code == 39 ||
code == 40 ||
code == 41 ||
code == 43 ||
code == 95 ||
code == 44 ||
code == 45 ||
code == 46 ||
code == 47 ||
code == 58 ||
code == 61 ||
code == 63 ||
code == 32
}