Skip to main content

ssh_cli/
paths.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! File path validation and normalization.
5//!
6//! Cross-platform guards against path traversal, Windows reserved device
7//! names, forbidden characters, Unicode NFC drift (macOS NFD vs Linux NFC),
8//! and legacy Windows `MAX_PATH` (260) limits without the `\\?\` prefix.
9
10use crate::errors::{SshCliError, SshCliResult};
11use std::path::Path;
12use unicode_normalization::UnicodeNormalization;
13
14#[inline]
15fn path_err(msg: impl Into<String>) -> SshCliError {
16    SshCliError::InvalidArgument(msg.into())
17}
18
19/// Names reserved by the Windows file system (case-insensitive).
20const WINDOWS_RESERVED_NAMES: &[&str] = &[
21    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
22    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
23];
24
25/// Characters forbidden in file names (Windows-illegal or shell-hostile on Unix).
26const FORBIDDEN_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
27
28/// Legacy Windows `MAX_PATH` including the trailing NUL (Win32 default without long-path prefix).
29pub const WINDOWS_MAX_PATH: usize = 260;
30
31/// Maximum length of a single path component on Windows (excluding separators).
32pub const WINDOWS_MAX_COMPONENT: usize = 255;
33
34const _: () = assert!(!WINDOWS_RESERVED_NAMES.is_empty());
35const _: () = assert!(!FORBIDDEN_CHARS.is_empty());
36const _: () = assert!(WINDOWS_MAX_PATH > WINDOWS_MAX_COMPONENT);
37
38/// Validates a file name (no path separators).
39///
40/// Rejects:
41/// - Empty strings
42/// - Names with `..` components (path traversal)
43/// - Forbidden characters
44/// - Windows reserved names (case-insensitive)
45/// - Names ending with a dot or space (problematic on Windows)
46///
47/// # Examples
48///
49/// ```
50/// use ssh_cli::paths::validate_name;
51///
52/// assert!(validate_name("meu-servidor").is_ok());
53/// assert!(validate_name("../etc/passwd").is_err());
54/// assert!(validate_name("CON").is_err());
55/// ```
56///
57/// # Errors
58/// Returns [`SshCliError::InvalidArgument`] if the name is empty, contains
59/// traversal/forbidden characters/whitespace, or is Windows-reserved.
60pub fn validate_name(name: &str) -> SshCliResult<()> {
61    if name.is_empty() {
62        return Err(path_err("file name cannot be empty"));
63    }
64
65    if name.contains("..") {
66        return Err(path_err(format!(
67            "file name contains path traversal component: '{name}'"
68        )));
69    }
70
71    for c in FORBIDDEN_CHARS {
72        if name.contains(*c) {
73            return Err(path_err(format!(
74                "file name contains forbidden character '{}': '{name}'",
75                c.escape_default()
76            )));
77        }
78    }
79
80    let name_upper = name.to_uppercase();
81    // Also checks without extension (e.g. "NUL.txt" is forbidden on Windows)
82    let root = name_upper.split('.').next().unwrap_or(&name_upper);
83    if WINDOWS_RESERVED_NAMES.contains(&root) {
84        return Err(path_err(format!(
85            "file name uses a Windows reserved name: '{name}'"
86        )));
87    }
88
89    if name.ends_with('.') || name.ends_with(' ') {
90        return Err(path_err(format!(
91            "file name cannot end with a dot or space: '{name}'"
92        )));
93    }
94
95    // GAP-AUD-VAL-001: reject any internal whitespace (spaces/tabs) so VPS registry
96    // keys stay shell/TOML/agent-safe single tokens.
97    if name.chars().any(|c| c.is_whitespace()) {
98        return Err(path_err(format!(
99            "file name cannot contain whitespace: '{name}'"
100        )));
101    }
102
103    Ok(())
104}
105
106/// Normalizes a file name to Unicode NFC form.
107///
108/// NFC normalization is required for consistent comparisons across OSes
109/// (macOS often stores NFD; Linux typically uses NFC).
110///
111/// # Examples
112///
113/// ```
114/// use ssh_cli::paths::normalize_nfc;
115///
116/// let nfc = normalize_nfc("cafe");
117/// assert_eq!(nfc, "cafe");
118/// assert_eq!(normalize_nfc(&nfc), nfc); // idempotent
119/// ```
120#[must_use]
121pub fn normalize_nfc(name: &str) -> String {
122    name.nfc().collect()
123}
124
125/// Validates and normalizes a file name in one operation.
126///
127/// Returns the NFC-normalized name if all validations pass.
128///
129/// # Examples
130///
131/// ```
132/// use ssh_cli::paths::validate_and_normalize;
133///
134/// let name = validate_and_normalize("lab-01").unwrap();
135/// assert_eq!(name.as_str(), "lab-01");
136/// assert!(validate_and_normalize("../etc").is_err());
137/// ```
138///
139/// # Errors
140/// Returns [`SshCliError::Domain`] / [`SshCliError::InvalidArgument`] if
141/// [`validate_name`] / [`crate::domain::VpsName::try_new`] fails.
142pub fn validate_and_normalize(name: &str) -> SshCliResult<crate::domain::VpsName> {
143    // G-TYPE-08: return refined type (proof not discarded as bare String).
144    // DomainError maps via From → SshCliError::Domain (G-ERR-02).
145    Ok(crate::domain::VpsName::try_new(name)?)
146}
147
148/// Validates that a path has no traversal components.
149///
150/// Checks all path segments separated by `/` or `\`.
151///
152/// # Examples
153///
154/// ```
155/// use ssh_cli::paths::validate_no_traversal;
156///
157/// assert!(validate_no_traversal("/tmp/file.bin").is_ok());
158/// assert!(validate_no_traversal("a/../../etc/passwd").is_err());
159/// assert!(validate_no_traversal("").is_err());
160/// ```
161///
162/// # Errors
163/// Empty path or any `..` segment → [`SshCliError::InvalidArgument`].
164pub fn validate_no_traversal(path: &str) -> SshCliResult<()> {
165    if path.is_empty() {
166        return Err(path_err("path cannot be empty"));
167    }
168
169    let segments = path.split(['/', '\\']);
170    for segment in segments {
171        if segment == ".." {
172            return Err(path_err(format!(
173                "path contains path traversal component: '{path}'"
174            )));
175        }
176    }
177
178    Ok(())
179}
180
181/// Default cap for primary-key files (hex is 64 chars; allow whitespace/BOM).
182pub const MAX_SECRETS_KEY_FILE_BYTES: u64 = 4_096;
183
184/// Cap for `config.toml` (local agent registry — not a multi-tenant store).
185pub const MAX_CONFIG_TOML_BYTES: u64 = 4 * 1024 * 1024;
186
187/// Cap for TOFU `known_hosts` text file.
188pub const MAX_KNOWN_HOSTS_BYTES: u64 = 1_024 * 1024;
189
190/// Maximum size of a PEM file accepted by `tls mtls import`.
191///
192/// Unlike the other caps here, this one guards an operator-supplied path rather than a
193/// file the CLI wrote itself. `tls mtls import` read it with an unbounded `fs::read`,
194/// so pointing the flag at a large file — by typo or by a script composing paths —
195/// pulled the whole thing into memory before anything validated it. A full certificate
196/// chain with a private key is a few kilobytes; 1 MiB is generous by three orders of
197/// magnitude and still bounded.
198pub const MAX_PEM_FILE_BYTES: u64 = 1_024 * 1024;
199
200const _: () = assert!(MAX_SECRETS_KEY_FILE_BYTES >= 64);
201const _: () = assert!(MAX_CONFIG_TOML_BYTES >= 4_096);
202const _: () = assert!(MAX_KNOWN_HOSTS_BYTES >= 256);
203
204/// Resolves the XDG config directory for [`crate::constants::APP_NAME`].
205///
206/// Uses `directories::ProjectDirs` (Linux: `$XDG_CONFIG_HOME/ssh-cli` or
207/// `~/.config/ssh-cli`). Does **not** honor `SSH_CLI_HOME` or `--config-dir`
208/// — callers layer those overrides themselves.
209///
210/// # Errors
211/// Returns [`SshCliError::XdgDirectory`] when the home/config root cannot be
212/// determined (rare headless environments) — G-ERR-03.
213pub fn xdg_config_dir() -> SshCliResult<std::path::PathBuf> {
214    directories::ProjectDirs::from(
215        crate::constants::PROJECT_QUALIFIER,
216        crate::constants::PROJECT_ORGANIZATION,
217        crate::constants::APP_NAME,
218    )
219    .map(|d| d.config_dir().to_path_buf())
220    .ok_or(SshCliError::XdgDirectory)
221}
222
223/// Returns `true` when `path` uses the Windows extended-length prefix (`\\?\` or `//?/`).
224#[must_use]
225pub fn has_windows_long_path_prefix(path: &Path) -> bool {
226    let s = path.as_os_str().to_string_lossy();
227    s.starts_with(r"\\?\") || s.starts_with("//?/")
228}
229
230/// Validates a local filesystem path against Windows legacy length limits.
231///
232/// On **all** platforms this checks component length (≤255) so config written
233/// on Linux remains openable if copied to Windows. On **Windows** (or when
234/// `force_windows_rules` is true in tests), rejects total path length ≥
235/// [`WINDOWS_MAX_PATH`] unless the path already uses the `\\?\` long-path
236/// prefix.
237///
238/// Remote SCP paths are **not** validated here (remote FS is Unix-like).
239///
240/// # Errors
241/// Returns [`SshCliError::InvalidArgument`] when a component or the full path
242/// exceeds platform limits.
243pub fn validate_local_path_length(path: &Path) -> SshCliResult<()> {
244    validate_local_path_length_inner(path, cfg!(windows))
245}
246
247fn validate_local_path_length_inner(path: &Path, enforce_windows_total: bool) -> SshCliResult<()> {
248    // Split on both separators so Windows-style paths are validated even when
249    // this code runs on Unix hosts (CI, cross-compile checks, agent sandboxes).
250    let raw = path.as_os_str().to_string_lossy();
251    let stripped = raw
252        .strip_prefix(r"\\?\")
253        .or_else(|| raw.strip_prefix("//?/"))
254        .unwrap_or(raw.as_ref());
255    for segment in stripped.split(['/', '\\']) {
256        if segment.is_empty() || segment == "." || segment == ".." {
257            continue;
258        }
259        // Drive letter ("C:") is not subject to the 255-byte file-name limit.
260        if segment.len() == 2 && segment.as_bytes()[1] == b':' {
261            continue;
262        }
263        if segment.len() > WINDOWS_MAX_COMPONENT {
264            return Err(path_err(format!(
265                "path component exceeds {WINDOWS_MAX_COMPONENT} bytes (Windows limit): '{segment}'"
266            )));
267        }
268    }
269
270    if enforce_windows_total && !has_windows_long_path_prefix(path) {
271        // Lossy UTF-8 length as a conservative Win32 MAX_PATH estimate.
272        let encoded_len = raw.len();
273        // Win32 MAX_PATH counts the trailing NUL; reject at 259 visible chars.
274        if encoded_len >= WINDOWS_MAX_PATH - 1 {
275            return Err(path_err(format!(
276                "local path length {encoded_len} approaches Windows MAX_PATH ({WINDOWS_MAX_PATH}); \
277                 use a shorter path or the \\\\?\\ extended-length prefix"
278            )));
279        }
280    }
281
282    Ok(())
283}
284
285/// Reads a UTF-8 text file with a hard byte cap (memory / OOM hygiene).
286///
287/// Checks `metadata().len()` first, then reads with `Take(max+1)` so a TOCTOU
288/// grow cannot allocate unbounded heap. Rejects files larger than `max_bytes`.
289///
290/// # Errors
291/// Returns [`std::io::Error`] on I/O failure or when the file exceeds `max_bytes`.
292pub fn read_text_capped(path: &std::path::Path, max_bytes: u64) -> std::io::Result<String> {
293    use std::io::Read;
294
295    let meta = std::fs::metadata(path)?;
296    if meta.len() > max_bytes {
297        return Err(std::io::Error::new(
298            std::io::ErrorKind::InvalidData,
299            format!(
300                "file {} exceeds max size of {max_bytes} bytes",
301                path.display()
302            ),
303        ));
304    }
305
306    let file = std::fs::File::open(path)?;
307    let mut limited = file.take(max_bytes.saturating_add(1));
308    let mut buf = String::new();
309    limited.read_to_string(&mut buf)?;
310    if (buf.len() as u64) > max_bytes {
311        return Err(std::io::Error::new(
312            std::io::ErrorKind::InvalidData,
313            format!(
314                "file {} exceeds max size of {max_bytes} bytes",
315                path.display()
316            ),
317        ));
318    }
319    Ok(buf)
320}
321
322/// Reads a file as bytes, refusing anything larger than `max_bytes`.
323///
324/// Byte-oriented twin of [`read_text_capped`] for content that is not required to be
325/// UTF-8 — PEM material is base64 in practice but its DER neighbours are not, and
326/// rejecting a valid certificate over an encoding assumption would be a worse failure
327/// than the one this cap exists to prevent.
328///
329/// The size is checked twice on purpose: `metadata` can race with a writer, so the
330/// `take` provides the guarantee and the up-front check merely avoids opening a file
331/// that is already known to be too large.
332///
333/// # Errors
334/// [`std::io::ErrorKind::InvalidData`] when the file exceeds `max_bytes`; any other
335/// I/O error from `metadata`, `open` or the read itself.
336pub fn read_bytes_capped(path: &std::path::Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
337    use std::io::Read;
338
339    let meta = std::fs::metadata(path)?;
340    if meta.len() > max_bytes {
341        return Err(std::io::Error::new(
342            std::io::ErrorKind::InvalidData,
343            format!(
344                "file {} exceeds max size of {max_bytes} bytes",
345                path.display()
346            ),
347        ));
348    }
349
350    let file = std::fs::File::open(path)?;
351    let mut limited = file.take(max_bytes.saturating_add(1));
352    // Sized from the metadata rather than grown from empty: the length is already known
353    // and the cap above bounds it, so one allocation replaces a doubling sequence.
354    let mut buf = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
355    limited.read_to_end(&mut buf)?;
356    if (buf.len() as u64) > max_bytes {
357        return Err(std::io::Error::new(
358            std::io::ErrorKind::InvalidData,
359            format!(
360                "file {} exceeds max size of {max_bytes} bytes",
361                path.display()
362            ),
363        ));
364    }
365    Ok(buf)
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn common_valid_name_passes() {
374        assert!(validate_name("meu-servidor").is_ok());
375        assert!(validate_name("vps_01").is_ok());
376        assert!(validate_name("servidor.produção").is_ok());
377    }
378
379    #[test]
380    fn empty_name_rejected() {
381        assert!(validate_name("").is_err());
382    }
383
384    #[test]
385    fn path_traversal_rejected() {
386        assert!(validate_name("..").is_err());
387        assert!(validate_name("../etc/passwd").is_err());
388        assert!(validate_name("foo/../bar").is_err());
389    }
390
391    #[test]
392    fn forbidden_chars_rejected() {
393        assert!(validate_name("foo/bar").is_err());
394        assert!(validate_name("foo\\bar").is_err());
395        assert!(validate_name("foo:bar").is_err());
396        assert!(validate_name("foo*bar").is_err());
397        assert!(validate_name("foo?bar").is_err());
398    }
399
400    #[test]
401    fn windows_reserved_names_rejected() {
402        assert!(validate_name("CON").is_err());
403        assert!(validate_name("con").is_err());
404        assert!(validate_name("NUL.txt").is_err());
405        assert!(validate_name("COM1").is_err());
406        assert!(validate_name("LPT9").is_err());
407    }
408
409    #[test]
410    fn name_ending_with_dot_rejected() {
411        assert!(validate_name("file.").is_err());
412    }
413
414    #[test]
415    fn name_with_internal_space_rejected() {
416        assert!(validate_name("a b").is_err());
417        assert!(validate_name("a\tb").is_err());
418    }
419
420    #[test]
421    fn name_ending_with_space_rejected() {
422        assert!(validate_name("file ").is_err());
423    }
424
425    #[test]
426    fn normalize_nfc_returns_string() {
427        let result = normalize_nfc("servidor");
428        assert_eq!(result, "servidor");
429    }
430
431    #[test]
432    fn validate_and_normalize_returns_valid_string() {
433        let result = validate_and_normalize("meu-servidor").unwrap();
434        assert_eq!(result.as_str(), "meu-servidor");
435    }
436
437    #[test]
438    fn validate_no_traversal_accepts_normal_path() {
439        assert!(validate_no_traversal("/home/usuario/file.txt").is_ok());
440        assert!(validate_no_traversal("relative/path/file.txt").is_ok());
441    }
442
443    #[test]
444    fn validate_no_traversal_rejects_traversal() {
445        assert!(validate_no_traversal("/home/../etc/passwd").is_err());
446        assert!(validate_no_traversal("../secreto").is_err());
447    }
448
449    #[test]
450    fn validate_no_traversal_rejects_empty() {
451        assert!(validate_no_traversal("").is_err());
452    }
453
454    #[test]
455    fn name_with_brazilian_accents_valid() {
456        assert!(validate_name("produção").is_ok());
457        assert!(validate_name("ação-configuração").is_ok());
458    }
459
460    #[test]
461    fn name_with_cjk_unicode_valid() {
462        assert!(validate_name("server-\u{4e16}\u{754c}").is_ok());
463    }
464
465    #[test]
466    fn name_with_emoji_valid() {
467        assert!(validate_name("server-\u{1f680}").is_ok());
468    }
469
470    #[test]
471    fn windows_reserved_mixed_case_rejected() {
472        assert!(validate_name("cOn").is_err());
473        assert!(validate_name("Nul").is_err());
474        assert!(validate_name("lPt1").is_err());
475    }
476
477    #[test]
478    fn normalize_nfc_converts_nfd_to_nfc() {
479        let nfd = "e\u{0301}"; // e + combining acute
480        let nfc = "\u{00e9}"; // é precomposed
481        assert_eq!(normalize_nfc(nfd), nfc);
482    }
483
484    #[test]
485    fn normalize_nfc_preserves_nfc() {
486        let nfc = "\u{00e9}";
487        assert_eq!(normalize_nfc(nfc), nfc);
488    }
489
490    #[test]
491    fn normalize_nfc_idempotent() {
492        let input = "cafe\u{0301}";
493        let once = normalize_nfc(input);
494        let twice = normalize_nfc(&once);
495        assert_eq!(once, twice);
496    }
497
498    #[test]
499    fn validate_and_normalize_converts_nfd() {
500        let result = validate_and_normalize("cafe\u{0301}").unwrap();
501        assert_eq!(result.as_str(), "caf\u{00e9}");
502    }
503
504    #[test]
505    fn validate_no_traversal_rejects_backslash() {
506        assert!(validate_no_traversal("foo\\..\\bar").is_err());
507    }
508
509    #[test]
510    fn validate_no_traversal_accepts_dot_alone() {
511        assert!(validate_no_traversal("./file").is_ok());
512    }
513
514    #[test]
515    fn long_path_prefix_detected() {
516        assert!(has_windows_long_path_prefix(Path::new(r"\\?\C:\very\long")));
517        assert!(has_windows_long_path_prefix(Path::new("//?/C:/very/long")));
518        assert!(!has_windows_long_path_prefix(Path::new(r"C:\short")));
519    }
520
521    #[test]
522    fn component_over_255_rejected() {
523        let long = "a".repeat(WINDOWS_MAX_COMPONENT + 1);
524        let p = Path::new(&long);
525        let err = validate_local_path_length_inner(p, false).unwrap_err();
526        assert!(err.to_string().contains("255"));
527    }
528
529    #[test]
530    fn windows_total_path_limit_enforced() {
531        // Build a path whose lossy length is >= 259 without long-path prefix.
532        let mut s = String::from("C:");
533        while s.len() < WINDOWS_MAX_PATH - 1 {
534            s.push_str("\\seg");
535        }
536        let p = Path::new(&s);
537        assert!(validate_local_path_length_inner(p, true).is_err());
538        let extended = format!(r"\\?\{s}");
539        assert!(validate_local_path_length_inner(Path::new(&extended), true).is_ok());
540    }
541
542    #[test]
543    fn short_local_path_ok() {
544        assert!(
545            validate_local_path_length(Path::new("/home/user/.config/ssh-cli/config.toml")).is_ok()
546        );
547    }
548
549    #[test]
550    fn read_text_capped_accepts_small_file() {
551        let dir = tempfile::tempdir().unwrap();
552        let p = dir.path().join("k.txt");
553        std::fs::write(&p, "abc").unwrap();
554        let s = read_text_capped(&p, 64).unwrap();
555        assert_eq!(s, "abc");
556    }
557
558    #[test]
559    fn read_text_capped_rejects_oversize() {
560        let dir = tempfile::tempdir().unwrap();
561        let p = dir.path().join("big.txt");
562        std::fs::write(&p, "0123456789").unwrap();
563        let err = read_text_capped(&p, 4).unwrap_err();
564        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
565    }
566}