1use std::path::{Component, Path, PathBuf};
2
3fn workspace_canonical(workspace: &Path) -> PathBuf {
4 workspace
5 .canonicalize()
6 .unwrap_or_else(|_| workspace.to_path_buf())
7}
8
9fn validate_within_workspace(
10 workspace: &Path,
11 resolved: &Path,
12 original: &str,
13) -> anyhow::Result<PathBuf> {
14 let canonical_workspace = workspace_canonical(workspace);
15 if !resolved.starts_with(&canonical_workspace) {
16 anyhow::bail!(
17 "Access denied: path '{}' is outside the workspace.",
18 original
19 );
20 }
21 Ok(resolved.to_path_buf())
22}
23
24fn resolve_write_candidate(requested: &Path) -> PathBuf {
25 let mut prefix = if requested.is_absolute() {
26 PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
27 } else {
28 PathBuf::new()
29 };
30 let mut canonical_prefix = prefix.clone();
31 let mut remainder = PathBuf::new();
32 let mut saw_missing_component = false;
33
34 for component in requested.components() {
35 match component {
36 Component::RootDir | Component::Prefix(_) => {}
37 Component::CurDir => {}
38 Component::ParentDir => {
39 if saw_missing_component {
40 remainder.pop();
41 } else {
42 prefix.pop();
43 if prefix.exists() {
44 canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
45 }
46 }
47 }
48 Component::Normal(part) => {
49 if saw_missing_component {
50 remainder.push(part);
51 continue;
52 }
53
54 prefix.push(part);
55 if prefix.exists() {
56 canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
57 } else {
58 saw_missing_component = true;
59 remainder.push(part);
60 }
61 }
62 }
63 }
64
65 if saw_missing_component {
66 canonical_prefix.join(remainder)
67 } else {
68 canonical_prefix
69 }
70}
71
72pub fn resolve_workspace_existing_path(
73 workspace: &Path,
74 raw_path: &str,
75) -> anyhow::Result<PathBuf> {
76 let path = raw_path.trim();
77 if path.is_empty() {
78 anyhow::bail!("Path is required");
79 }
80 if path.starts_with('~') {
81 anyhow::bail!("Home directory expansion (~) is disabled for security.");
82 }
83
84 let requested = if Path::new(path).is_absolute() {
85 PathBuf::from(path)
86 } else {
87 workspace.join(path)
88 };
89 let resolved = requested
90 .canonicalize()
91 .map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", raw_path, e))?;
92
93 validate_within_workspace(workspace, &resolved, raw_path)
94}
95
96pub fn resolve_workspace_write_path(workspace: &Path, raw_path: &str) -> anyhow::Result<PathBuf> {
97 let path = raw_path.trim();
98 if path.is_empty() {
99 anyhow::bail!("Path is required");
100 }
101 if path.starts_with('~') {
102 anyhow::bail!("Home directory expansion (~) is disabled for security.");
103 }
104
105 let requested = if Path::new(path).is_absolute() {
106 PathBuf::from(path)
107 } else {
108 workspace.join(path)
109 };
110 let resolved = resolve_write_candidate(&requested);
111 validate_within_workspace(workspace, &resolved, raw_path)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn rejects_absolute_escape() {
120 let tmp = tempfile::tempdir().unwrap();
121 let ws = tmp.path().join("workspace");
122 std::fs::create_dir_all(&ws).unwrap();
123 let err = resolve_workspace_write_path(&ws, "/etc/passwd").unwrap_err();
124 assert!(err.to_string().contains("outside the workspace"));
125 }
126
127 #[test]
128 fn rejects_relative_traversal() {
129 let tmp = tempfile::tempdir().unwrap();
130 let ws = tmp.path().join("workspace");
131 std::fs::create_dir_all(&ws).unwrap();
132 let outside = tmp.path().join("secrets.txt");
133 std::fs::write(&outside, "secret").unwrap();
134 let err = resolve_workspace_existing_path(&ws, "../secrets.txt").unwrap_err();
135 assert!(err.to_string().contains("outside the workspace"));
136 }
137}