1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
use std::borrow::Cow;
// Follow from https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts
//
// Every character that must be replaced is ASCII (<= 0x7F). UTF-8 guarantees that no byte of a
// multi-byte character is < 0x80, so scanning bytes finds exactly the same positions as scanning
// chars — without decoding — and every match lands on a char boundary.
const fn is_invalid_byte(byte: u8) -> bool {
matches!(
byte,
0x00
..=0x1f // C0 control characters
| b'"'
| b'#'
| b'$'
| b'%'
| b'&'
| b'*'
| b'+'
| b','
| b':'
| b';'
| b'<'
| b'='
| b'>'
| b'?'
| b'['
| b']'
| b'^'
| b'`'
| b'{'
| b'|'
| b'}'
| 0x7f // DEL
)
}
pub fn default_sanitize_file_name(str: &str) -> Cow<'_, str> {
// A `:` is only allowed as part of a windows drive letter (ex: C:\foo).
// Otherwise, avoid them because they can refer to NTFS alternate data streams.
// Skip the two-byte drive prefix so its `:` is preserved, but every later `:`
// is still treated as invalid. Both prefix bytes are ASCII, so `2` is a char boundary.
let scan_start = if starts_with_windows_drive(str) { 2 } else { 0 };
let Some(first_invalid) = str.as_bytes()[scan_start..]
.iter()
.position(|&byte| is_invalid_byte(byte))
.map(|idx| scan_start + idx)
else {
// Common case: nothing needs sanitizing, return the input untouched (no allocation).
return Cow::Borrowed(str);
};
// Allocate only on the path that actually rewrites the name. The valid prefix
// (drive letter included) is copied in bulk instead of one char at a time.
let mut sanitized = String::with_capacity(str.len());
sanitized.push_str(&str[..first_invalid]);
for char in str[first_invalid..].chars() {
// Invalid chars are all ASCII; `u8::try_from` keeps non-ASCII chars (and any char above
// U+00FF) on the copy path instead of truncating them into a false match (e.g. `😀 as u8`).
match u8::try_from(char) {
Ok(byte) if is_invalid_byte(byte) => sanitized.push('_'),
_ => sanitized.push(char),
}
}
Cow::Owned(sanitized)
}
fn starts_with_windows_drive(str: &str) -> bool {
let mut chars = str.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
chars.next().is_some_and(|c| c == ':')
}
#[test]
fn test_sanitize_file_name() {
assert_eq!(default_sanitize_file_name("\0+a=Z_0-"), "__a_Z_0-");
// Clean inputs are returned untouched and borrowed (no allocation).
assert_eq!(default_sanitize_file_name("index.js"), "index.js");
assert!(matches!(default_sanitize_file_name("a/b/c.js"), Cow::Borrowed("a/b/c.js")));
assert!(matches!(default_sanitize_file_name(""), Cow::Borrowed("")));
// A drive-letter `:` is preserved, but any later `:` is replaced.
assert!(matches!(default_sanitize_file_name("C:/foo.js"), Cow::Borrowed("C:/foo.js")));
assert_eq!(default_sanitize_file_name("C:/a:b.js"), "C:/a_b.js");
// Dirty inputs are rewritten and owned.
assert!(matches!(default_sanitize_file_name("a?b"), Cow::Owned(_)));
}
#[test]
fn test_sanitize_unicode() {
// Clean multi-byte names contain no invalid bytes, so they are returned untouched and
// borrowed — the byte scan must never mistake a UTF-8 byte (>= 0x80) for an ASCII invalid
// char. Covers 2-, 3-, and 4-byte sequences.
assert!(matches!(default_sanitize_file_name("café.js"), Cow::Borrowed("café.js")));
assert!(matches!(default_sanitize_file_name("日本語.js"), Cow::Borrowed("日本語.js")));
assert!(matches!(
default_sanitize_file_name("компоненты/Кнопка.js"),
Cow::Borrowed("компоненты/Кнопка.js")
));
assert!(matches!(default_sanitize_file_name("emoji_😀.js"), Cow::Borrowed("emoji_😀.js")));
// On the rewrite path, multi-byte chars must survive verbatim and never be truncated into a
// false match: `é as u8 == 0xe9` (in range, not invalid) and `😀 as u8 == 0x00` (would
// otherwise hit the control-char range). Only the ASCII `?` / `:` are replaced.
assert_eq!(default_sanitize_file_name("a?é"), "a_é");
assert_eq!(default_sanitize_file_name("a?😀"), "a_😀");
assert_eq!(default_sanitize_file_name("日本?語"), "日本_語");
assert_eq!(default_sanitize_file_name("café:dir.js"), "café_dir.js");
}