// ===========================================================================
// moon-multipart — Content-Type & Content-Disposition header parsing
// ===========================================================================
// ---------------------------------------------------------------------------
// Boundary validation & extraction
// ---------------------------------------------------------------------------
///|
pub fn validate_boundary(boundary : String) -> Result[String, MultipartError] {
let len = boundary.length()
if len == 0 {
return Err(InvalidBoundary("boundary must not be empty".to_string()))
}
if len > 70 {
return Err(InvalidBoundary("boundary exceeds 70 chars: " + len.to_string()))
}
let bytes = @utf8.encode(boundary)
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if b < b' ' || b > b'~' {
return Err(InvalidBoundary("illegal char at position " + i.to_string()))
}
}
Ok(boundary.to_string())
}
///|
pub fn parse_boundary_from_content_type(
content_type : String,
) -> Result[String, MultipartError] {
let bytes = @utf8.encode(content_type)
match find_param(bytes, b"boundary") {
None => Err(MissingBoundary)
Some(eq_pos) =>
match extract_value(content_type, eq_pos) {
None => Err(MissingBoundary)
Some(v) => validate_boundary(v)
}
}
}
// ---------------------------------------------------------------------------
// Content-Disposition parsing (with strict/compat mode)
// ---------------------------------------------------------------------------
///|
/// Parse Content-Disposition header. Returns (name, filename?).
/// In strict mode with reject_filename_star, filename* is rejected.
pub fn parse_content_disposition(
header_value : String,
opts : ParseOptions,
) -> Result[(String, String?), MultipartError] {
let trimmed = header_value.trim().to_owned()
if !trimmed.has_prefix("form-data") {
return Err(MalformedHeader("must start with 'form-data'".to_string()))
}
let name = extract_named_param(header_value, "name")
// Check for filename* (RFC 5987, rejected by RFC 7578)
let fstar = extract_named_param(header_value, "filename*")
match fstar {
Some(_) =>
if opts.reject_filename_star {
return Err(
NonCompliantHeader("filename* is not allowed by RFC 7578".to_string()),
)
}
None => ()
}
let filename = if opts.reject_filename_star {
extract_named_param(header_value, "filename")
} else {
match fstar {
Some(fs) => Some(fs)
None => extract_named_param(header_value, "filename")
}
}
match name {
None => Err(MissingName)
Some(n) => {
// Validate filename
match filename {
Some(f) => {
if f.length() > opts.limits.max_filename_len {
return Err(
FilenameTooLong(f.to_string(), opts.limits.max_filename_len),
)
}
if is_dangerous(f) {
return Err(PathTraversal(f.to_string()))
}
}
None => ()
}
Ok((n, filename))
}
}
}
///|
/// Legacy parse_content_disposition without options (backwards compat).
pub fn parse_content_disposition_simple(
header_value : String,
) -> Result[(String, String?), MultipartError] {
parse_content_disposition(header_value, ParseOptions::default())
}
// ---------------------------------------------------------------------------
// Parameter extraction
// ---------------------------------------------------------------------------
///|
fn find_param(header_bytes : Bytes, param_name : Bytes) -> Int? {
let hlen = header_bytes.length()
let plen = param_name.length()
if hlen < plen {
return None
}
let mut i = 0
while i <= hlen - plen {
let mut matched = true
let mut j = 0
while j < plen {
if ascii_lower(header_bytes[i + j]) != ascii_lower(param_name[j]) {
matched = false
j = plen
} else {
j = j + 1
}
}
if matched {
let before_ok = i == 0 ||
header_bytes[i - 1] == b';' ||
header_bytes[i - 1] == b' ' ||
header_bytes[i - 1] == b'\t'
let mut k = i + plen
while k < hlen && (header_bytes[k] == b' ' || header_bytes[k] == b'\t') {
k = k + 1
}
if before_ok && k < hlen && header_bytes[k] == b'=' {
return Some(k)
}
}
i = i + 1
}
None
}
///|
fn ascii_lower(b : Byte) -> Byte {
let v = b.to_int()
if v >= 65 && v <= 90 {
(v + 32).to_byte()
} else {
b
}
}
///|
fn extract_value(header : String, eq_pos : Int) -> String? {
let total = header.length()
let mut start = eq_pos + 1
while start < total {
let c = header[start]
if c == ' ' || c == '\t' {
start = start + 1
} else {
break
}
}
if start >= total {
return None
}
if header[start] == '"' {
let mut end = start + 1
while end < total {
if header[end] == '"' {
return Some(header[start + 1:end].to_owned())
}
if header[end] == '\\' && end + 1 < total {
end = end + 1
}
end = end + 1
}
return Some(header[start + 1:].to_owned())
}
let mut end = start
while end < total {
if header[end] == ';' {
break
}
end = end + 1
}
Some(header[start:end].trim().to_owned())
}
///|
fn extract_named_param(header_val : String, param_name : String) -> String? {
let hb = @utf8.encode(header_val)
let pb = @utf8.encode(param_name)
match find_param(hb, pb) {
None => None
Some(pos) => extract_value(header_val, pos)
}
}
// ---------------------------------------------------------------------------
// Header line parsing
// ---------------------------------------------------------------------------
///|
pub fn parse_header_line(line : String) -> (String, String)? {
match line.find(":") {
None => None
Some(i) => {
let name = line[0:i].trim().to_owned()
let value = line[i + 1:].trim().to_owned()
if name.length() > 0 {
Some((name, value))
} else {
None
}
}
}
}
///|
pub fn is_content_type(name : String) -> Bool {
name.trim().equal_ignore_ascii_case("content-type")
}
///|
pub fn is_content_disposition(name : String) -> Bool {
name.trim().equal_ignore_ascii_case("content-disposition")
}
// ---------------------------------------------------------------------------
// Filename danger detection
// ---------------------------------------------------------------------------
///|
fn is_dangerous(filename : String) -> Bool {
if filename.contains("..") {
return true
}
if filename.contains("\\") {
return true
}
if filename.has_prefix("/") || filename.has_prefix("\\\\") {
return true
}
// Null byte check via byte scan
let fbytes_check = @utf8.encode(filename)
let mut has_null = false
for i = 0; i < fbytes_check.length(); i = i + 1 {
if fbytes_check[i] == b'\x00' {
has_null = true
}
}
if has_null {
return true
}
let fbytes = @utf8.encode(filename)
if fbytes.length() >= 2 && is_ascii_alpha(fbytes[0]) && fbytes[1] == b':' {
return true
}
false
}
///|
fn is_ascii_alpha(b : Byte) -> Bool {
let v = b.to_int()
(v >= 65 && v <= 90) || (v >= 97 && v <= 122)
}