///|
pub fn sanitize_filename(filename : String) -> FilenameReport {
sanitize_filename_with_policy(filename, FilenamePolicy::default())
}
///|
pub fn sanitize_filename_with_policy(
filename : String,
policy : FilenamePolicy,
) -> FilenameReport {
let diagnostics : Array[Diagnostic] = []
let extracted = last_path_segment(filename)
if extracted != filename {
push_diag(
diagnostics,
PathSegmentStripped,
Warning,
"Path components were stripped from the filename",
None,
)
}
let collapsed = collapse_unsafe_chars(extracted, policy, diagnostics)
let trimmed = trim_filename_edges(collapsed)
let non_empty = if trimmed.is_empty() {
push_diag(
diagnostics,
EmptyFilename,
Warning,
"Filename was empty after normalization",
None,
)
policy.default_name
} else {
trimmed
}
let visible = if !policy.allow_hidden && non_empty.has_prefix(".") {
push_diag(
diagnostics,
UnsafeFilename,
Warning,
"Leading dot was replaced to avoid hidden files",
Some(0),
)
policy.replacement + non_empty[1:].to_owned()
} else {
non_empty
}
let reserved_safe = avoid_reserved_device_name(visible, policy, diagnostics)
let length_safe = limit_filename_length(reserved_safe, policy, diagnostics)
{
value: length_safe,
original: filename,
changed: length_safe != filename,
diagnostics,
}
}
///|
fn last_path_segment(filename : String) -> String {
let mut last = 0
let mut index = 0
while index < filename.length() {
let c = char_at(filename, index)
if c == '/' || c == '\\' {
last = index + 1
}
index = index + c.utf16_len()
}
filename[last:].to_owned()
}
///|
fn collapse_unsafe_chars(
filename : String,
policy : FilenamePolicy,
diagnostics : Array[Diagnostic],
) -> String {
let buf = StringBuilder(size_hint=filename.length())
let mut changed = false
for c in filename.iter() {
if is_filename_unsafe(c) {
changed = true
if is_ctl(c) {
push_diag(
diagnostics,
ControlCharacter,
Warning,
"Control character was replaced in filename",
None,
)
}
buf.write_string(policy.replacement)
} else {
buf.write_char(c)
}
}
if changed {
push_diag(
diagnostics,
UnsafeFilename,
Warning,
"Unsafe filename characters were replaced",
None,
)
}
coalesce_replacement(buf.to_string(), policy.replacement)
}
///|
fn is_filename_unsafe(c : Char) -> Bool {
is_ctl(c) ||
c == '/' ||
c == '\\' ||
c == ':' ||
c == '*' ||
c == '?' ||
c == '"' ||
c == '<' ||
c == '>' ||
c == '|'
}
///|
fn trim_filename_edges(filename : String) -> String {
filename.trim(chars=" \t\r\n").to_owned().trim_end(chars=".").to_owned()
}
///|
fn coalesce_replacement(filename : String, replacement : String) -> String {
if replacement.is_empty() {
return filename
}
let buf = StringBuilder(size_hint=filename.length())
let mut previous = false
for c in filename.iter() {
let s = c.to_string()
if s == replacement {
if !previous {
buf.write_string(replacement)
}
previous = true
} else {
buf.write_char(c)
previous = false
}
}
buf.to_string()
}
///|
fn avoid_reserved_device_name(
filename : String,
policy : FilenamePolicy,
diagnostics : Array[Diagnostic],
) -> String {
let stem = filename_stem_for_reserved_check(filename)
if is_reserved_device_name(stem) {
push_diag(
diagnostics,
ReservedName,
Warning,
"Reserved device filename was prefixed",
None,
)
policy.replacement + filename
} else {
filename
}
}
///|
fn filename_stem_for_reserved_check(filename : String) -> String {
let trimmed = filename.trim(chars=" \t.").to_owned()
match trimmed.split_once(".") {
Some((stem, _)) => stem.to_owned().to_lower()
None => trimmed.to_lower()
}
}
///|
fn is_reserved_device_name(stem : String) -> Bool {
stem == "con" ||
stem == "prn" ||
stem == "aux" ||
stem == "nul" ||
stem == "clock$" ||
is_numbered_reserved(stem, "com") ||
is_numbered_reserved(stem, "lpt")
}
///|
fn is_numbered_reserved(stem : String, prefix : String) -> Bool {
if !stem.has_prefix(prefix) {
return false
}
let rest = stem[prefix.length():].to_owned()
rest.length() == 1 && char_at(rest, 0) >= '1' && char_at(rest, 0) <= '9'
}
///|
fn limit_filename_length(
filename : String,
policy : FilenamePolicy,
diagnostics : Array[Diagnostic],
) -> String {
if policy.max_length <= 0 || filename.char_length() <= policy.max_length {
return filename
}
push_diag(
diagnostics,
LengthReduced,
Warning,
"Filename was shortened to fit the configured length limit",
None,
)
take_chars(filename, policy.max_length)
}
///|
fn take_chars(filename : String, max_chars : Int) -> String {
let buf = StringBuilder(size_hint=max_chars)
let mut count = 0
for c in filename.iter() {
if count >= max_chars {
break
}
buf.write_char(c)
count = count + 1
}
buf.to_string()
}
///|
pub fn safe_filename_from_header(
header : String,
) -> Result[FilenameReport, ParseError] {
match parse_header(header) {
Ok(disposition) =>
match disposition.filename() {
Some(name) => Ok(sanitize_filename(name))
None =>
Err({
code: MissingValue,
message: "Header does not contain a filename parameter",
offset: None,
})
}
Err(err) => Err(err)
}
}
///|
pub fn filename_ascii_fallback(filename : String) -> String {
sanitize_filename(ascii_fallback(filename)).value
}