// ===========================================================================
// moon-multipart — Safe filename handling
// ===========================================================================
///|
/// Check if a filename contains path traversal or other dangerous patterns.
pub fn is_dangerous_filename(filename : String) -> Bool {
if filename.contains("..") {
return true
}
if filename.contains("\\") {
return true
}
if filename.has_prefix("/") || filename.has_prefix("\\\\") {
return true
}
let fb = @utf8.encode(filename)
if fb.length() > 0 &&
(fb[fb.length() - 1] == b' ' || fb[fb.length() - 1] == b'.') {
return true
}
if fb.length() >= 2 && is_alpha(fb[0]) && fb[1] == b':' {
return true
}
let mut has_null = false
for i = 0; i < fb.length(); i = i + 1 {
if fb[i] == b'\x00' {
has_null = true
}
}
if has_null {
return true
}
if is_windows_reserved_name(filename) {
return true
}
false
}
///|
/// Validate a filename for security. Returns Ok(filename) or Err.
pub fn validate_filename(
filename : String,
max_len : Int,
) -> Result[String, MultipartError] {
if filename.trim().length() == 0 {
return Err(PathTraversal("empty filename".to_string()))
}
if filename.length() > max_len {
return Err(FilenameTooLong(filename, max_len))
}
if is_dangerous_filename(filename) {
return Err(PathTraversal(filename))
}
Ok(filename.to_string())
}
///|
/// Produce a safe, sanitized filename by removing dangerous characters.
pub fn safe_filename(original : String) -> String {
let chars : Array[Char] = []
let byte_data = @utf8.encode(original)
let mut i = 0
let blen = byte_data.length()
while i < blen {
let b = byte_data[i]
if b == b'/' || b == b'\\' || b == b'\x00' {
chars.push('_')
i = i + 1
continue
}
if i + 1 < blen && byte_data[i] == b'.' && byte_data[i + 1] == b'.' {
chars.push('_')
chars.push('_')
i = i + 2
continue
}
chars.push(b.to_char())
i = i + 1
}
let result = trim_windows_tail(String::from_array(chars))
// Check for drive letter
let rb = @utf8.encode(result)
if rb.length() >= 2 && is_alpha(rb[0]) && rb[1] == b':' {
return make_windows_safe(strip_dangerous(result[2:].to_owned()))
}
make_windows_safe(strip_dangerous(result))
}
///|
/// Generate a unique safe filename preserving the original extension.
pub fn unique_filename(original : String) -> String {
let safe = safe_filename(original)
match safe.rev_find(".") {
None => safe + "_" + hex8()
Some(idx) => safe[0:idx].to_owned() + "_" + hex8() + safe[idx:].to_owned()
}
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
///|
fn is_alpha(b : Byte) -> Bool {
let v = b.to_int()
(v >= 65 && v <= 90) || (v >= 97 && v <= 122)
}
///|
fn strip_dangerous(s : String) -> String {
let bytes = @utf8.encode(s)
let chars : Array[Char] = []
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if b >= b'0' && b <= b'9' {
chars.push(b.to_char())
continue
}
if b >= b'A' && b <= b'Z' {
chars.push(b.to_char())
continue
}
if b >= b'a' && b <= b'z' {
chars.push(b.to_char())
continue
}
if b == b'-' || b == b'_' || b == b'.' {
chars.push(b.to_char())
} else {
chars.push('_')
}
}
String::from_array(chars)
}
///|
/// Remove trailing spaces and dots, which Windows silently discards.
fn trim_windows_tail(s : String) -> String {
let bytes = @utf8.encode(s)
let mut end = bytes.length()
while end > 0 && (bytes[end - 1] == b' ' || bytes[end - 1] == b'.') {
end = end - 1
}
if end == 0 {
"_"
} else {
s[0:end].to_owned()
}
}
///|
/// Prefix Windows device names so a sanitized filename is safe to create.
fn make_windows_safe(s : String) -> String {
if is_windows_reserved_name(s) {
"_" + s
} else {
s
}
}
///|
/// Check Windows reserved device names, with or without an extension.
fn is_windows_reserved_name(filename : String) -> Bool {
let stem = match filename.find(".") {
Some(index) => filename[0:index].to_owned()
None => filename.to_string()
}
let lower = stem.to_lower()
if lower == "con" || lower == "prn" || lower == "aux" || lower == "nul" {
return true
}
let bytes = @utf8.encode(lower)
if bytes.length() != 4 || bytes[3] < b'1' || bytes[3] > b'9' {
return false
}
(bytes[0] == b'c' && bytes[1] == b'o' && bytes[2] == b'm') ||
(bytes[0] == b'l' && bytes[1] == b'p' && bytes[2] == b't')
}
///|
fn hex8() -> String {
let hex_chars = "0123456789ABCDEF".to_array()
let chars : Array[Char] = []
for i = 0; i < 8; i = i + 1 {
let idx = (i * 13 + 7) % 16
chars.push(hex_chars[idx])
}
String::from_array(chars)
}