Skip to main content

ssh_cli/
paths.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! File path validation and normalization.
3//!
4//! Provides functions to validate file names safely and
5//! cross-platform, prevenindo path traversal, nomes reservados do Windows
6//! and forbidden characters.
7
8use anyhow::{bail, Result};
9use unicode_normalization::UnicodeNormalization;
10
11/// Names reserved by the Windows file system (case-insensitive).
12const 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
17/// Characters forbidden in file names (forbidden on Windows or problematic
18/// em sistemas Unix).
19const FORBIDDEN_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
20
21/// Validates a file name (no path separators).
22///
23/// Rejeita:
24/// - Strings vazias.
25/// - Names with `..` components (path traversal).
26/// - Forbidden characters.
27/// - Windows reserved names (case-insensitive).
28/// - Names ending with a dot or space (problematic on Windows).
29///
30/// # Examples
31///
32/// ```
33/// use ssh_cli::paths::validate_name;
34///
35/// assert!(validate_name("meu-servidor").is_ok());
36/// assert!(validate_name("../etc/passwd").is_err());
37/// assert!(validate_name("CON").is_err());
38/// ```
39///
40/// # Errors
41/// Returns an error if the name is empty, contains traversal/forbidden characters/whitespace, or is Windows-reserved.
42pub 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    // Also checks without extension (e.g. "NUL.txt" is forbidden on Windows)
62    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    // GAP-AUD-VAL-001: reject any internal whitespace (spaces/tabs) so VPS registry
72    // keys stay shell/TOML/agent-safe single tokens.
73    if name.chars().any(|c| c.is_whitespace()) {
74        bail!("file name cannot contain whitespace: '{name}'");
75    }
76
77    Ok(())
78}
79
80/// Normalizes a file name to Unicode NFC form.
81///
82/// NFC normalization is required for consistent comparisons
83/// entre diferentes sistemas operacionais (macOS usa NFD, Linux usa NFC).
84#[must_use]
85pub fn normalize_nfc(name: &str) -> String {
86    name.nfc().collect()
87}
88
89/// Validates and normalizes a file name in one operation.
90///
91/// Returns the NFC-normalized name if all validations pass.
92///
93/// # Errors
94/// Returns an error if [`validate_name`] fails.
95pub fn validate_and_normalize(name: &str) -> Result<String> {
96    validate_name(name)?;
97    Ok(normalize_nfc(name))
98}
99
100/// Validates that a path has no traversal components.
101///
102/// Checks all path segments separated by `/` or `\`.
103pub 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}"; // e + combining acute
230        let nfc = "\u{00e9}"; // é precomposed
231        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}