// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// One part of a multipart body: its header fields and its body octets.
pub(all) struct Part {
headers : Array[(String, String)]
body : Bytes
} derive(Eq, Debug)
///|
pub extend Part with Eq::{equal, not_equal}
///|
pub extend Part with Debug::{to_repr}
///|
/// One of the part's own headers, by lowercased name.
pub fn Part::header(self : Part, name : StringView) -> String? {
header_of(self.headers, name)
}
///|
/// The part's `Content-Type`, parsed; `None` when it has none.
pub fn Part::content_type(self : Part) -> MediaType? {
match self.header("content-type") {
Some(value) => Some(media_type(value[:]))
None => None
}
}
///|
/// The part's `Content-Disposition`, parsed; `None` when it has none.
pub fn Part::disposition(self : Part) -> Disposition? {
match self.header("content-disposition") {
Some(value) => Some(Disposition::parse(value[:]))
None => None
}
}
///|
/// What a body is allowed to be.
///
/// A body arrives from whoever sent it, so both bounds are needed: a million
/// empty parts and one enormous part are different ways of asking a server to
/// allocate more than it has.
pub(all) struct Limits {
parts : Int32
part_size : Int32
} derive(Eq, Debug)
///|
pub extend Limits with Eq::{equal, not_equal}
///|
pub extend Limits with Debug::{to_repr}
///|
/// A thousand parts of a megabyte each — what Starlette's `max_files` and
/// `max_fields` default to, with python-multipart's part size. A caller that
/// knows its own shape should say so.
pub let limits : Limits = { parts: 1000, part_size: 1 << 20, }
///|
/// Build a bound by naming the parts that differ from the default.
pub fn Limits::new(
parts~ : Int32 = limits.parts,
part_size~ : Int32 = limits.part_size,
) -> Limits {
{ parts, part_size, }
}
///|
/// Why a body was not read as a form.
///
/// A body over its bounds is refused whole rather than truncated: a handler
/// given the first thousand parts of a larger body would be answering a request
/// nobody sent.
pub(all) suberror Refused {
/// More parts than allowed.
Parts(limit~ : Int32)
/// One part longer than allowed.
Part(limit~ : Int32, got~ : Int32)
/// A body that stops making sense at this offset. Only raised under
/// `strict`; otherwise the parts read so far are returned.
Malformed(at~ : Int32)
/// A content type that is not a form encoding, for whoever picks a decoder by
/// `Content-Type` (moonhttp's `form`) and was asked to say so.
NotAForm(content_type~ : String)
} derive(Eq, Debug)
///|
pub extend Refused with Eq::{equal, not_equal}
///|
pub extend Refused with Debug::{to_repr}
///|
/// Split a multipart body into its parts (RFC 2046 §5.1) given its boundary.
///
/// Preamble and epilogue are dropped; the two CRLF before each delimiter belong
/// to the delimiter, not to the part. A body that stops making sense returns
/// what it read so far, unless `strict` says to refuse it.
pub fn multipart(
body : BytesView,
boundary : StringView,
limits~ : Limits = limits,
strict~ : Bool = false,
) -> Array[Part] raise Refused {
let parts : Array[Part] = []
let dash = ascii("--" + boundary.to_owned())
let mut pos = index_of(body, dash[:], 0)
while pos >= 0 {
let after = pos + dash.length()
// `--boundary--` closes the body; there is nothing after it but an epilogue.
if has_at(body, after, b"--"[:]) {
break
}
let head = if has_at(body, after, b"\r\n"[:]) { after + 2 } else { after }
let blank = index_of(body, b"\r\n\r\n"[:], head)
let next = index_of(body, dash[:], head)
if blank < 0 || next < 0 || blank > next {
// Inventing a structure for the rest would be deciding what the sender
// meant, so the choice is between stopping with what was read and saying
// so; `strict` is which.
if strict {
raise Malformed(at=head)
}
break
}
if parts.length() == limits.parts {
raise Parts(limit=limits.parts)
}
let headers = part_headers(body[head:blank])
let from = blank + 4
// The two bytes before the next delimiter are this part's own trailing
// CRLF and are not content.
let to = if next - 2 < from { from } else { next - 2 }
if to - from > limits.part_size {
raise Part(limit=limits.part_size, got=to - from)
}
parts.push({ headers, body: body[from:to].to_owned(), })
pos = next
}
parts
}
// ----------------------------------------------------------------- the pieces
///|
/// A part's header block, as lowercased name and value.
///
/// A line with no colon is not a header field and is dropped rather than
/// guessed at.
fn part_headers(block : BytesView) -> Array[(String, String)] {
let out : Array[(String, String)] = []
let line = text(block)
let n = line.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || line.at(i).to_int() == 0x0A {
let end = if i > start && line.at(i - 1).to_int() == 0x0D {
i - 1
} else {
i
}
let one = line[start:end]
let mut colon = -1
for j = 0; j < one.length(); j = j + 1 {
if one.at(j).to_int() == 0x3A {
colon = j
break
}
}
if colon > 0 {
out.push((trim(one[0:colon]).to_lower(), trim(one[colon + 1:])))
}
start = i + 1
}
}
out
}
///|
fn header_of(headers : Array[(String, String)], name : StringView) -> String? {
for pair in headers {
if pair.0[:] == name {
return Some(pair.1)
}
}
None
}
///|
/// Bytes as text, with anything that is not UTF-8 replaced rather than refused.
fn text(raw : BytesView) -> String {
@utf8.decode_lossy(raw)
}
///|
fn ascii(s : String) -> Bytes {
let out : Array[Byte] = []
for i = 0; i < s.length(); i = i + 1 {
out.push((s.at(i).to_int() & 0xFF).to_byte())
}
Bytes::from_array(out)
}
///|
fn has_at(hay : BytesView, at : Int32, needle : BytesView) -> Bool {
if at < 0 || at + needle.length() > hay.length() {
return false
}
for i = 0; i < needle.length(); i = i + 1 {
if hay[at + i] != needle[i] {
return false
}
}
true
}
///|
fn index_of(hay : BytesView, needle : BytesView, from : Int32) -> Int32 {
let last = hay.length() - needle.length()
for i = from; i <= last; i = i + 1 {
if has_at(hay, i, needle) {
return i
}
}
-1
}