Skip to main content

codex_patcher/
safety.rs

1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4/// Workspace safety checks to prevent editing files outside the target workspace.
5#[derive(Debug, Clone)]
6pub struct WorkspaceGuard {
7    /// Absolute path to workspace root
8    workspace_root: PathBuf,
9    /// Canonical paths to forbidden directories
10    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    /// Create a new workspace guard with the given root.
27    ///
28    /// The workspace root will be canonicalized to handle symlinks correctly.
29    pub fn new(workspace_root: impl AsRef<Path>) -> Result<Self, SafetyError> {
30        let workspace_root = workspace_root.as_ref().canonicalize()?;
31
32        // Build list of forbidden directories
33        let mut forbidden_paths = Vec::new();
34
35        // ~/.cargo/registry - dependency source code
36        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        // ~/.rustup - toolchain installations
46        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        // target/ directory within workspace
53        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    /// Check if a path is safe to edit.
64    ///
65    /// Returns the canonicalized absolute path if safe.
66    ///
67    /// Note: This performs canonicalization at validation time. For maximum
68    /// TOCTOU safety, callers should hold an open fd or re-validate immediately
69    /// before write operations in adversarial environments.
70    pub fn validate_path(&self, path: impl AsRef<Path>) -> Result<PathBuf, SafetyError> {
71        let path = path.as_ref();
72
73        // Resolve relative paths against workspace root
74        let absolute = if path.is_absolute() {
75            path.to_path_buf()
76        } else {
77            self.workspace_root.join(path)
78        };
79
80        // Canonicalize to resolve symlinks and .. components
81        let canonical = absolute.canonicalize()?;
82
83        self.check_canonical(&canonical)?;
84
85        Ok(canonical)
86    }
87
88    /// Re-validate a previously-validated canonical path.
89    ///
90    /// Call this immediately before write to close the TOCTOU window:
91    /// the path is re-canonicalized and re-checked against workspace
92    /// and forbidden boundaries.
93    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        // Check if inside workspace
101        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        // Check against forbidden paths
109        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    /// Get the workspace root.
122    pub fn workspace_root(&self) -> &Path {
123        &self.workspace_root
124    }
125
126    /// Create a guard with custom forbidden paths (for testing).
127    #[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        // Validate relative path
200        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        // Should reject because canonical path is outside workspace
223        assert!(matches!(result, Err(SafetyError::OutsideWorkspace { .. })));
224    }
225}