flymcprust_core/
security.rs1use std::path::{Component, Path, PathBuf};
4
5use crate::tool::ToolError;
6
7pub fn confine_path(root: impl AsRef<Path>, relative: &str) -> Result<PathBuf, ToolError> {
38 if relative.is_empty() || relative.chars().any(char::is_control) {
39 return Err(ToolError::not_found());
40 }
41 let normalized = relative.replace('\\', "/");
44 let mut clean = PathBuf::new();
45 for component in Path::new(&normalized).components() {
46 match component {
47 Component::Normal(part) => clean.push(part),
48 Component::CurDir => {}
49 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
50 return Err(ToolError::not_found());
51 }
52 }
53 }
54 if clean.as_os_str().is_empty() || (cfg!(windows) && normalized.contains(':')) {
55 return Err(ToolError::not_found());
57 }
58 let root = root
59 .as_ref()
60 .canonicalize()
61 .map_err(|_| ToolError::not_found())?;
62 let resolved = root
63 .join(&clean)
64 .canonicalize()
65 .map_err(|_| ToolError::not_found())?;
66 if resolved.starts_with(&root) {
67 Ok(resolved)
68 } else {
69 Err(ToolError::not_found())
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 fn workspace() -> PathBuf {
78 let dir = std::env::temp_dir().join(format!(
79 "flymcprust-confine-{}-{:?}",
80 std::process::id(),
81 std::thread::current().id()
82 ));
83 std::fs::create_dir_all(dir.join("sub")).unwrap();
84 std::fs::write(dir.join("sub").join("a.txt"), "a").unwrap();
85 dir
86 }
87
88 #[test]
89 fn accepts_paths_inside_the_root() {
90 let root = workspace();
91 let p = confine_path(&root, "sub/a.txt").unwrap();
92 assert!(p.ends_with("a.txt"));
93 assert!(confine_path(&root, "./sub/./a.txt").is_ok());
94 assert!(confine_path(&root, "sub\\a.txt").is_ok());
95 }
96
97 #[test]
98 fn rejects_escapes() {
99 let root = workspace();
100 for bad in [
101 "",
102 "../outside",
103 "sub/../../outside",
104 "..\\outside",
105 "/etc/passwd",
106 "\\\\server\\share",
107 "C:\\Windows\\win.ini",
108 "sub/a.txt\0",
109 "sub/\na.txt",
110 "missing.txt",
111 ] {
112 assert!(
113 confine_path(&root, bad).is_err(),
114 "{bad:?} must be rejected"
115 );
116 }
117 }
118
119 #[cfg(unix)]
120 #[test]
121 fn rejects_symlinks_leaving_the_root() {
122 let root = workspace();
123 let link = root.join("escape");
124 let _ = std::fs::remove_file(&link);
125 std::os::unix::fs::symlink("/", &link).unwrap();
126 assert!(confine_path(&root, "escape/etc").is_err());
127 }
128}