Skip to main content

ssh_cli/
paths.rs

1//! Validação e normalização de caminhos de arquivo.
2//!
3//! Fornece funções para validar nomes de arquivo de forma segura e
4//! cross-platform, prevenindo path traversal, nomes reservados do Windows
5//! e caracteres proibidos.
6
7use anyhow::{bail, Result};
8use unicode_normalization::UnicodeNormalization;
9
10/// Nomes reservados pelo sistema de arquivos do Windows (case-insensitive).
11const 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
16/// Caracteres proibidos em nomes de arquivo (proibidos no Windows ou problemáticos
17/// em sistemas Unix).
18const CHARS_PROIBIDOS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
19
20/// Valida um nome de arquivo (sem separadores de caminho).
21///
22/// Rejeita:
23/// - Strings vazias.
24/// - Nomes com componentes `..` (path traversal).
25/// - Caracteres proibidos.
26/// - Nomes reservados do Windows (case-insensitive).
27/// - Nomes que terminam com ponto ou espaço (problemáticos no Windows).
28///
29/// # Examples
30///
31/// ```
32/// use ssh_cli::paths::validar_nome;
33///
34/// assert!(validar_nome("meu-servidor").is_ok());
35/// assert!(validar_nome("../etc/passwd").is_err());
36/// assert!(validar_nome("CON").is_err());
37/// ```
38pub 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    // Verifica também sem extensão (ex.: "NUL.txt" é proibido no Windows)
58    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/// Normaliza um nome de arquivo para a forma NFC do Unicode.
71///
72/// A normalização NFC é necessária para garantir comparações consistentes
73/// entre diferentes sistemas operacionais (macOS usa NFD, Linux usa NFC).
74#[must_use]
75pub fn normalizar_nfc(nome: &str) -> String {
76    nome.nfc().collect()
77}
78
79/// Valida e normaliza um nome de arquivo em uma única operação.
80///
81/// Retorna o nome normalizado para NFC se passar em todas as validações.
82pub fn validar_e_normalizar(nome: &str) -> Result<String> {
83    validar_nome(nome)?;
84    Ok(normalizar_nfc(nome))
85}
86
87/// Valida que um caminho não contém componentes de path traversal.
88///
89/// Verifica todos os segmentos do caminho separados por `/` ou `\`.
90pub 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}"; // e + combining acute
211        let nfc = "\u{00e9}"; // é precomposed
212        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}