1use anyhow::{bail, Result};
8use unicode_normalization::UnicodeNormalization;
9
10const NOMES_RESERVADOS_WINDOWS: &[&str] = &[
12 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
13 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
14];
15
16const CHARS_PROIBIDOS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
19
20pub fn validar_nome(nome: &str) -> Result<()> {
39 if nome.is_empty() {
40 bail!("nome de arquivo não pode ser vazio");
41 }
42
43 if nome.contains("..") {
44 bail!("nome de arquivo contém componente de path traversal: '{nome}'");
45 }
46
47 for c in CHARS_PROIBIDOS {
48 if nome.contains(*c) {
49 bail!(
50 "nome de arquivo contém caractere proibido '{}': '{nome}'",
51 c.escape_default()
52 );
53 }
54 }
55
56 let nome_upper = nome.to_uppercase();
57 let raiz = nome_upper.split('.').next().unwrap_or(&nome_upper);
59 if NOMES_RESERVADOS_WINDOWS.contains(&raiz) {
60 bail!("nome de arquivo usa nome reservado do Windows: '{nome}'");
61 }
62
63 if nome.ends_with('.') || nome.ends_with(' ') {
64 bail!("nome de arquivo não pode terminar com ponto ou espaço: '{nome}'");
65 }
66
67 Ok(())
68}
69
70#[must_use]
75pub fn normalizar_nfc(nome: &str) -> String {
76 nome.nfc().collect()
77}
78
79pub fn validar_e_normalizar(nome: &str) -> Result<String> {
83 validar_nome(nome)?;
84 Ok(normalizar_nfc(nome))
85}
86
87pub fn validar_sem_traversal(caminho: &str) -> Result<()> {
91 if caminho.is_empty() {
92 bail!("caminho não pode ser vazio");
93 }
94
95 let segmentos = caminho.split(['/', '\\']);
96 for segmento in segmentos {
97 if segmento == ".." {
98 bail!("caminho contém componente de path traversal: '{caminho}'");
99 }
100 }
101
102 Ok(())
103}
104
105#[cfg(test)]
106mod testes {
107 use super::*;
108
109 #[test]
110 fn nome_valido_comum_passa() {
111 assert!(validar_nome("meu-servidor").is_ok());
112 assert!(validar_nome("vps_01").is_ok());
113 assert!(validar_nome("servidor.produção").is_ok());
114 }
115
116 #[test]
117 fn nome_vazio_rejeitado() {
118 assert!(validar_nome("").is_err());
119 }
120
121 #[test]
122 fn path_traversal_rejeitado() {
123 assert!(validar_nome("..").is_err());
124 assert!(validar_nome("../etc/passwd").is_err());
125 assert!(validar_nome("foo/../bar").is_err());
126 }
127
128 #[test]
129 fn chars_proibidos_rejeitados() {
130 assert!(validar_nome("foo/bar").is_err());
131 assert!(validar_nome("foo\\bar").is_err());
132 assert!(validar_nome("foo:bar").is_err());
133 assert!(validar_nome("foo*bar").is_err());
134 assert!(validar_nome("foo?bar").is_err());
135 }
136
137 #[test]
138 fn nomes_reservados_windows_rejeitados() {
139 assert!(validar_nome("CON").is_err());
140 assert!(validar_nome("con").is_err());
141 assert!(validar_nome("NUL.txt").is_err());
142 assert!(validar_nome("COM1").is_err());
143 assert!(validar_nome("LPT9").is_err());
144 }
145
146 #[test]
147 fn nome_terminando_com_ponto_rejeitado() {
148 assert!(validar_nome("arquivo.").is_err());
149 }
150
151 #[test]
152 fn nome_terminando_com_espaco_rejeitado() {
153 assert!(validar_nome("arquivo ").is_err());
154 }
155
156 #[test]
157 fn normalizar_nfc_retorna_string() {
158 let resultado = normalizar_nfc("servidor");
159 assert_eq!(resultado, "servidor");
160 }
161
162 #[test]
163 fn validar_e_normalizar_retorna_string_valida() {
164 let resultado = validar_e_normalizar("meu-servidor").unwrap();
165 assert_eq!(resultado, "meu-servidor");
166 }
167
168 #[test]
169 fn validar_sem_traversal_aceita_caminho_normal() {
170 assert!(validar_sem_traversal("/home/usuario/arquivo.txt").is_ok());
171 assert!(validar_sem_traversal("relative/path/file.txt").is_ok());
172 }
173
174 #[test]
175 fn validar_sem_traversal_rejeita_traversal() {
176 assert!(validar_sem_traversal("/home/../etc/passwd").is_err());
177 assert!(validar_sem_traversal("../secreto").is_err());
178 }
179
180 #[test]
181 fn validar_sem_traversal_rejeita_vazio() {
182 assert!(validar_sem_traversal("").is_err());
183 }
184
185 #[test]
186 fn nome_com_acentos_brasileiros_valido() {
187 assert!(validar_nome("produção").is_ok());
188 assert!(validar_nome("ação-configuração").is_ok());
189 }
190
191 #[test]
192 fn nome_com_unicode_cjk_valido() {
193 assert!(validar_nome("server-\u{4e16}\u{754c}").is_ok());
194 }
195
196 #[test]
197 fn nome_com_emoji_valido() {
198 assert!(validar_nome("server-\u{1f680}").is_ok());
199 }
200
201 #[test]
202 fn nome_windows_reservado_case_misto_rejeitado() {
203 assert!(validar_nome("cOn").is_err());
204 assert!(validar_nome("Nul").is_err());
205 assert!(validar_nome("lPt1").is_err());
206 }
207
208 #[test]
209 fn normalizar_nfc_converte_nfd_para_nfc() {
210 let nfd = "e\u{0301}"; let nfc = "\u{00e9}"; assert_eq!(normalizar_nfc(nfd), nfc);
213 }
214
215 #[test]
216 fn normalizar_nfc_preserva_nfc() {
217 let nfc = "\u{00e9}";
218 assert_eq!(normalizar_nfc(nfc), nfc);
219 }
220
221 #[test]
222 fn normalizar_nfc_idempotente() {
223 let input = "cafe\u{0301}";
224 let once = normalizar_nfc(input);
225 let twice = normalizar_nfc(&once);
226 assert_eq!(once, twice);
227 }
228
229 #[test]
230 fn validar_e_normalizar_nfd_converte() {
231 let resultado = validar_e_normalizar("cafe\u{0301}").unwrap();
232 assert_eq!(resultado, "caf\u{00e9}");
233 }
234
235 #[test]
236 fn validar_sem_traversal_com_backslash_rejeitado() {
237 assert!(validar_sem_traversal("foo\\..\\bar").is_err());
238 }
239
240 #[test]
241 fn validar_sem_traversal_dot_solo_aceita() {
242 assert!(validar_sem_traversal("./arquivo").is_ok());
243 }
244}