1use crate::error::{Error, Result};
5use std::path::{Path, PathBuf};
6use unicode_normalization::UnicodeNormalization;
7
8pub 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
13pub 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
61pub 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}