1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Clone)]
6pub struct WorkspaceGuard {
7 workspace_root: PathBuf,
9 forbidden_paths: Vec<PathBuf>,
11}
12
13#[derive(Error, Debug)]
14pub enum SafetyError {
15 #[error("Path is outside workspace: {path} (workspace: {workspace})")]
16 OutsideWorkspace { path: PathBuf, workspace: PathBuf },
17
18 #[error("Path is in forbidden directory: {path} (forbidden: {forbidden})")]
19 ForbiddenPath { path: PathBuf, forbidden: PathBuf },
20
21 #[error("Failed to canonicalize path: {0}")]
22 Canonicalize(#[from] std::io::Error),
23}
24
25impl WorkspaceGuard {
26 pub fn new(workspace_root: impl AsRef<Path>) -> Result<Self, SafetyError> {
30 let workspace_root = workspace_root.as_ref().canonicalize()?;
31
32 let mut forbidden_paths = Vec::new();
34
35 if let Some(home) = home::home_dir() {
37 if let Ok(cargo_registry) = home.join(".cargo/registry").canonicalize() {
38 forbidden_paths.push(cargo_registry);
39 }
40 if let Ok(cargo_git) = home.join(".cargo/git").canonicalize() {
41 forbidden_paths.push(cargo_git);
42 }
43 }
44
45 if let Some(home) = home::home_dir() {
47 if let Ok(rustup_home) = home.join(".rustup").canonicalize() {
48 forbidden_paths.push(rustup_home);
49 }
50 }
51
52 if let Ok(target_dir) = workspace_root.join("target").canonicalize() {
54 forbidden_paths.push(target_dir);
55 }
56
57 Ok(Self {
58 workspace_root,
59 forbidden_paths,
60 })
61 }
62
63 pub fn validate_path(&self, path: impl AsRef<Path>) -> Result<PathBuf, SafetyError> {
71 let path = path.as_ref();
72
73 let absolute = if path.is_absolute() {
75 path.to_path_buf()
76 } else {
77 self.workspace_root.join(path)
78 };
79
80 let canonical = absolute.canonicalize()?;
82
83 self.check_canonical(&canonical)?;
84
85 Ok(canonical)
86 }
87
88 pub fn revalidate(&self, path: &Path) -> Result<PathBuf, SafetyError> {
94 let canonical = path.canonicalize()?;
95 self.check_canonical(&canonical)?;
96 Ok(canonical)
97 }
98
99 fn check_canonical(&self, canonical: &Path) -> Result<(), SafetyError> {
100 if !canonical.starts_with(&self.workspace_root) {
102 return Err(SafetyError::OutsideWorkspace {
103 path: canonical.to_path_buf(),
104 workspace: self.workspace_root.clone(),
105 });
106 }
107
108 for forbidden in &self.forbidden_paths {
110 if canonical.starts_with(forbidden) {
111 return Err(SafetyError::ForbiddenPath {
112 path: canonical.to_path_buf(),
113 forbidden: forbidden.clone(),
114 });
115 }
116 }
117
118 Ok(())
119 }
120
121 pub fn workspace_root(&self) -> &Path {
123 &self.workspace_root
124 }
125
126 #[cfg(test)]
128 pub fn with_forbidden(
129 workspace_root: impl AsRef<Path>,
130 forbidden: Vec<PathBuf>,
131 ) -> Result<Self, SafetyError> {
132 let workspace_root = workspace_root.as_ref().canonicalize()?;
133 Ok(Self {
134 workspace_root,
135 forbidden_paths: forbidden,
136 })
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::fs;
144
145 #[test]
146 fn test_validate_path_inside_workspace() {
147 let temp_dir = tempfile::tempdir().unwrap();
148 let workspace = temp_dir.path();
149 let guard = WorkspaceGuard::new(workspace).unwrap();
150
151 let file = workspace.join("src/main.rs");
152 fs::create_dir_all(file.parent().unwrap()).unwrap();
153 fs::write(&file, b"").unwrap();
154
155 let result = guard.validate_path(&file);
156 assert!(result.is_ok());
157 }
158
159 #[test]
160 fn test_validate_path_outside_workspace() {
161 let temp_dir = tempfile::tempdir().unwrap();
162 let workspace = temp_dir.path().join("workspace");
163 fs::create_dir_all(&workspace).unwrap();
164 let guard = WorkspaceGuard::new(&workspace).unwrap();
165
166 let outside = temp_dir.path().join("outside.rs");
167 fs::write(&outside, b"").unwrap();
168
169 let result = guard.validate_path(&outside);
170 assert!(matches!(result, Err(SafetyError::OutsideWorkspace { .. })));
171 }
172
173 #[test]
174 fn test_validate_path_forbidden() {
175 let temp_dir = tempfile::tempdir().unwrap();
176 let workspace = temp_dir.path();
177 let forbidden = workspace.join("target");
178 fs::create_dir_all(&forbidden).unwrap();
179
180 let guard = WorkspaceGuard::with_forbidden(workspace, vec![forbidden.clone()]).unwrap();
181
182 let file = forbidden.join("debug/binary");
183 fs::create_dir_all(file.parent().unwrap()).unwrap();
184 fs::write(&file, b"").unwrap();
185
186 let result = guard.validate_path(&file);
187 assert!(matches!(result, Err(SafetyError::ForbiddenPath { .. })));
188 }
189
190 #[test]
191 fn test_validate_relative_path() {
192 let temp_dir = tempfile::tempdir().unwrap();
193 let workspace = temp_dir.path();
194 let guard = WorkspaceGuard::new(workspace).unwrap();
195
196 let file = workspace.join("test.rs");
197 fs::write(&file, b"").unwrap();
198
199 let result = guard.validate_path("test.rs");
201 assert!(result.is_ok());
202 }
203
204 #[test]
205 #[cfg(unix)]
206 fn test_validate_symlink_escape() {
207 use std::os::unix::fs::symlink;
208
209 let temp_dir = tempfile::tempdir().unwrap();
210 let workspace = temp_dir.path().join("workspace");
211 fs::create_dir_all(&workspace).unwrap();
212
213 let outside = temp_dir.path().join("outside.rs");
214 fs::write(&outside, b"").unwrap();
215
216 let link = workspace.join("escape.rs");
217 symlink(&outside, &link).unwrap();
218
219 let guard = WorkspaceGuard::new(&workspace).unwrap();
220 let result = guard.validate_path(&link);
221
222 assert!(matches!(result, Err(SafetyError::OutsideWorkspace { .. })));
224 }
225}