Skip to main content

tsaur_core/
paths.rs

1//! Archive paths are labels, never destinations. They are normalised on the way in and
2//! validated again on the way out, so an archive can never write outside the target directory.
3
4use crate::error::{Error, Result};
5use std::path::{Path, PathBuf};
6use unicode_normalization::UnicodeNormalization;
7
8/// Components a path may have (`docs/V1-CONTRACT.md` §4).
9pub const MAX_DEPTH: usize = 256;
10
11const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"];
12
13/// Normalise a path for storage: NFC, forward slashes, relative, no `.`/`..`, no drive letters,
14/// no NUL/control characters, no `:` (NTFS alternate data streams), no Windows reserved names,
15/// no trailing spaces or dots in components, at most 4096 bytes.
16pub fn normalize(raw: &str) -> Result<String> {
17    let s: String = raw.replace('\\', "/").nfc().collect();
18    let s = s.trim_start_matches("./");
19    if s.is_empty() {
20        return Err(Error::Policy("empty path".into()));
21    }
22    if s.starts_with('/') {
23        return Err(Error::Policy(format!("absolute path rejected: {raw}")));
24    }
25    if s.len() >= 2 && s.as_bytes()[1] == b':' {
26        return Err(Error::Policy(format!("drive letter rejected: {raw}")));
27    }
28    if s.chars().any(|c| c == '\0' || c.is_control()) {
29        return Err(Error::Policy(format!("control character in path: {raw:?}")));
30    }
31    if s.contains(':') {
32        return Err(Error::Policy(format!("colon (alternate data stream) rejected: {raw}")));
33    }
34    let mut parts = Vec::new();
35    for comp in s.split('/') {
36        if comp.is_empty() {
37            return Err(Error::Policy(format!("empty path component: {raw}")));
38        }
39        if comp == "." || comp == ".." {
40            return Err(Error::Policy(format!("dot component rejected: {raw}")));
41        }
42        if comp.ends_with(' ') || comp.ends_with('.') {
43            return Err(Error::Policy(format!("trailing space/dot rejected: {raw}")));
44        }
45        let stem = comp.split('.').next().unwrap_or("").to_ascii_uppercase();
46        if RESERVED.contains(&stem.as_str()) {
47            return Err(Error::Policy(format!("reserved device name rejected: {raw}")));
48        }
49        parts.push(comp);
50    }
51    if parts.len() > MAX_DEPTH {
52        return Err(Error::Policy(format!("path deeper than {MAX_DEPTH} components")));
53    }
54    let joined = parts.join("/");
55    if joined.len() > 4096 {
56        return Err(Error::Policy("path longer than 4096 bytes".into()));
57    }
58    Ok(joined)
59}
60
61/// Join a validated archive path under `root`, component by component.
62pub fn safe_join(root: &Path, archive_path: &str) -> Result<PathBuf> {
63    let norm = normalize(archive_path)?;
64    let mut p = root.to_path_buf();
65    for comp in norm.split('/') {
66        p.push(comp);
67    }
68    Ok(p)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn rejects_bad_paths() {
77        for bad in ["../x", "a/../b", "/etc/passwd", "C:\\x", "a:b", "CON", "nul.txt", "a/", "x\0y", "dir/..", "name. "] {
78            assert!(normalize(bad).is_err(), "should reject {bad:?}");
79        }
80        let deep = vec!["d"; MAX_DEPTH + 1].join("/");
81        assert!(normalize(&deep).is_err(), "deeper than {MAX_DEPTH} components");
82        assert!(normalize(&vec!["d"; MAX_DEPTH].join("/")).is_ok());
83    }
84
85    #[test]
86    fn accepts_and_normalises() {
87        assert_eq!(normalize("./docs\\a.md").unwrap(), "docs/a.md");
88        assert_eq!(normalize("ok/file.txt").unwrap(), "ok/file.txt");
89    }
90}