1use anyhow::{bail, Result};
9use unicode_normalization::UnicodeNormalization;
10
11const WINDOWS_RESERVED_NAMES: &[&str] = &[
13 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
14 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
15];
16
17const FORBIDDEN_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
20
21pub fn validate_name(name: &str) -> Result<()> {
43 if name.is_empty() {
44 bail!("file name cannot be empty");
45 }
46
47 if name.contains("..") {
48 bail!("file name contains path traversal component: '{name}'");
49 }
50
51 for c in FORBIDDEN_CHARS {
52 if name.contains(*c) {
53 bail!(
54 "file name contains forbidden character '{}': '{name}'",
55 c.escape_default()
56 );
57 }
58 }
59
60 let name_upper = name.to_uppercase();
61 let root = name_upper.split('.').next().unwrap_or(&name_upper);
63 if WINDOWS_RESERVED_NAMES.contains(&root) {
64 bail!("file name uses a Windows reserved name: '{name}'");
65 }
66
67 if name.ends_with('.') || name.ends_with(' ') {
68 bail!("file name cannot end with a dot or space: '{name}'");
69 }
70
71 if name.chars().any(|c| c.is_whitespace()) {
74 bail!("file name cannot contain whitespace: '{name}'");
75 }
76
77 Ok(())
78}
79
80#[must_use]
85pub fn normalize_nfc(name: &str) -> String {
86 name.nfc().collect()
87}
88
89pub fn validate_and_normalize(name: &str) -> Result<String> {
96 validate_name(name)?;
97 Ok(normalize_nfc(name))
98}
99
100pub fn validate_no_traversal(path: &str) -> Result<()> {
104 if path.is_empty() {
105 bail!("path cannot be empty");
106 }
107
108 let segments = path.split(['/', '\\']);
109 for segment in segments {
110 if segment == ".." {
111 bail!("path contains path traversal component: '{path}'");
112 }
113 }
114
115 Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn common_valid_name_passes() {
124 assert!(validate_name("meu-servidor").is_ok());
125 assert!(validate_name("vps_01").is_ok());
126 assert!(validate_name("servidor.produção").is_ok());
127 }
128
129 #[test]
130 fn empty_name_rejected() {
131 assert!(validate_name("").is_err());
132 }
133
134 #[test]
135 fn path_traversal_rejected() {
136 assert!(validate_name("..").is_err());
137 assert!(validate_name("../etc/passwd").is_err());
138 assert!(validate_name("foo/../bar").is_err());
139 }
140
141 #[test]
142 fn forbidden_chars_rejected() {
143 assert!(validate_name("foo/bar").is_err());
144 assert!(validate_name("foo\\bar").is_err());
145 assert!(validate_name("foo:bar").is_err());
146 assert!(validate_name("foo*bar").is_err());
147 assert!(validate_name("foo?bar").is_err());
148 }
149
150 #[test]
151 fn windows_reserved_names_rejected() {
152 assert!(validate_name("CON").is_err());
153 assert!(validate_name("con").is_err());
154 assert!(validate_name("NUL.txt").is_err());
155 assert!(validate_name("COM1").is_err());
156 assert!(validate_name("LPT9").is_err());
157 }
158
159 #[test]
160 fn name_ending_with_dot_rejected() {
161 assert!(validate_name("file.").is_err());
162 }
163
164 #[test]
165 fn name_with_internal_space_rejected() {
166 assert!(validate_name("a b").is_err());
167 assert!(validate_name("a\tb").is_err());
168 }
169
170 #[test]
171 fn name_ending_with_space_rejected() {
172 assert!(validate_name("file ").is_err());
173 }
174
175 #[test]
176 fn normalize_nfc_returns_string() {
177 let result = normalize_nfc("servidor");
178 assert_eq!(result, "servidor");
179 }
180
181 #[test]
182 fn validate_and_normalize_returns_valid_string() {
183 let result = validate_and_normalize("meu-servidor").unwrap();
184 assert_eq!(result, "meu-servidor");
185 }
186
187 #[test]
188 fn validate_no_traversal_accepts_normal_path() {
189 assert!(validate_no_traversal("/home/usuario/file.txt").is_ok());
190 assert!(validate_no_traversal("relative/path/file.txt").is_ok());
191 }
192
193 #[test]
194 fn validate_no_traversal_rejects_traversal() {
195 assert!(validate_no_traversal("/home/../etc/passwd").is_err());
196 assert!(validate_no_traversal("../secreto").is_err());
197 }
198
199 #[test]
200 fn validate_no_traversal_rejects_empty() {
201 assert!(validate_no_traversal("").is_err());
202 }
203
204 #[test]
205 fn name_with_brazilian_accents_valid() {
206 assert!(validate_name("produção").is_ok());
207 assert!(validate_name("ação-configuração").is_ok());
208 }
209
210 #[test]
211 fn name_with_cjk_unicode_valid() {
212 assert!(validate_name("server-\u{4e16}\u{754c}").is_ok());
213 }
214
215 #[test]
216 fn name_with_emoji_valid() {
217 assert!(validate_name("server-\u{1f680}").is_ok());
218 }
219
220 #[test]
221 fn windows_reserved_mixed_case_rejected() {
222 assert!(validate_name("cOn").is_err());
223 assert!(validate_name("Nul").is_err());
224 assert!(validate_name("lPt1").is_err());
225 }
226
227 #[test]
228 fn normalize_nfc_converts_nfd_to_nfc() {
229 let nfd = "e\u{0301}"; let nfc = "\u{00e9}"; assert_eq!(normalize_nfc(nfd), nfc);
232 }
233
234 #[test]
235 fn normalize_nfc_preserves_nfc() {
236 let nfc = "\u{00e9}";
237 assert_eq!(normalize_nfc(nfc), nfc);
238 }
239
240 #[test]
241 fn normalize_nfc_idempotent() {
242 let input = "cafe\u{0301}";
243 let once = normalize_nfc(input);
244 let twice = normalize_nfc(&once);
245 assert_eq!(once, twice);
246 }
247
248 #[test]
249 fn validate_and_normalize_converts_nfd() {
250 let result = validate_and_normalize("cafe\u{0301}").unwrap();
251 assert_eq!(result, "caf\u{00e9}");
252 }
253
254 #[test]
255 fn validate_no_traversal_rejects_backslash() {
256 assert!(validate_no_traversal("foo\\..\\bar").is_err());
257 }
258
259 #[test]
260 fn validate_no_traversal_accepts_dot_alone() {
261 assert!(validate_no_traversal("./file").is_ok());
262 }
263}