Skip to main content

sandbox_workspace/
lib.rs

1//! # sandbox-workspace
2//!
3//! Safe workspace staging and patch filesystem helpers.
4//!
5//! This Tier 3 crate prepares controlled working directories and enforces
6//! workspace-relative file access for patch application and validation. It is a
7//! support layer for internal execution crates.
8
9use std::path::{Path, PathBuf};
10
11use forge_policy::{resolve_workspace_path, PolicyError};
12
13#[derive(Debug, thiserror::Error)]
14pub enum WorkspaceError {
15    #[error("io error: {0}")]
16    Io(#[from] std::io::Error),
17
18    #[error("policy error: {0}")]
19    Policy(#[from] PolicyError),
20}
21
22pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
23
24#[derive(Debug)]
25pub struct Workspace {
26    pub host_path: PathBuf,
27    pub _temp_dir: Option<tempfile::TempDir>,
28}
29
30#[derive(Debug, Clone)]
31pub struct PatchedWorkspace {
32    pub host_path: PathBuf,
33}
34
35pub trait PatchFs {
36    fn root(&self) -> &Path;
37    fn exists(&self, relative_path: &Path) -> WorkspaceResult<bool>;
38    fn read_lines(&self, relative_path: &Path) -> WorkspaceResult<Vec<String>>;
39    fn write_lines(&self, relative_path: &Path, lines: &[String]) -> WorkspaceResult<()>;
40    fn remove_file(&self, relative_path: &Path) -> WorkspaceResult<()>;
41    fn create_parent_dirs(&self, relative_path: &Path) -> WorkspaceResult<()>;
42    fn snapshot_lines(&self, relative_path: &Path) -> WorkspaceResult<Option<Vec<String>>>;
43}
44
45#[derive(Debug, Clone)]
46pub struct LocalPatchFs {
47    root: PathBuf,
48}
49
50impl LocalPatchFs {
51    pub fn new(root: &Path) -> Self {
52        Self {
53            root: root.to_path_buf(),
54        }
55    }
56
57    fn resolve(&self, relative_path: &Path) -> WorkspaceResult<PathBuf> {
58        Ok(resolve_workspace_path(&self.root, relative_path)?)
59    }
60}
61
62impl PatchFs for LocalPatchFs {
63    fn root(&self) -> &Path {
64        &self.root
65    }
66
67    fn exists(&self, relative_path: &Path) -> WorkspaceResult<bool> {
68        Ok(self.resolve(relative_path)?.exists())
69    }
70
71    fn read_lines(&self, relative_path: &Path) -> WorkspaceResult<Vec<String>> {
72        let full_path = self.resolve(relative_path)?;
73        let text = std::fs::read_to_string(full_path)?;
74        Ok(text.lines().map(String::from).collect())
75    }
76
77    fn write_lines(&self, relative_path: &Path, lines: &[String]) -> WorkspaceResult<()> {
78        let full_path = self.resolve(relative_path)?;
79        if let Some(parent) = full_path.parent() {
80            std::fs::create_dir_all(parent)?;
81        }
82
83        let rendered = if lines.is_empty() {
84            String::new()
85        } else {
86            let joined = lines.join("\n");
87            format!("{joined}\n")
88        };
89        std::fs::write(full_path, rendered)?;
90        Ok(())
91    }
92
93    fn remove_file(&self, relative_path: &Path) -> WorkspaceResult<()> {
94        let full_path = self.resolve(relative_path)?;
95        match std::fs::remove_file(full_path) {
96            Ok(()) => Ok(()),
97            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
98            Err(error) => Err(error.into()),
99        }
100    }
101
102    fn create_parent_dirs(&self, relative_path: &Path) -> WorkspaceResult<()> {
103        let full_path = self.resolve(relative_path)?;
104        if let Some(parent) = full_path.parent() {
105            std::fs::create_dir_all(parent)?;
106        }
107        Ok(())
108    }
109
110    fn snapshot_lines(&self, relative_path: &Path) -> WorkspaceResult<Option<Vec<String>>> {
111        let full_path = self.resolve(relative_path)?;
112        if !full_path.exists() {
113            return Ok(None);
114        }
115
116        let text = std::fs::read_to_string(full_path)?;
117        Ok(Some(text.lines().map(String::from).collect()))
118    }
119}
120
121pub fn prepare_workspace(fixture: &Path) -> WorkspaceResult<Workspace> {
122    let temp_dir = tempfile::TempDir::new()?;
123    copy_dir_recursive(fixture, temp_dir.path())?;
124    Ok(Workspace {
125        host_path: temp_dir.path().to_path_buf(),
126        _temp_dir: Some(temp_dir),
127    })
128}
129
130pub fn as_patched_workspace(workspace: &Workspace) -> PatchedWorkspace {
131    PatchedWorkspace {
132        host_path: workspace.host_path.clone(),
133    }
134}
135
136pub fn copy_dir_recursive_pub(src: &Path, dst: &Path) -> WorkspaceResult<()> {
137    copy_dir_recursive(src, dst)
138}
139
140fn copy_dir_recursive(src: &Path, dst: &Path) -> WorkspaceResult<()> {
141    if !src.exists() {
142        return Ok(());
143    }
144
145    for entry in walkdir::WalkDir::new(src)
146        .follow_links(false)
147        .into_iter()
148        .filter_map(|entry| entry.ok())
149    {
150        if entry.file_type().is_symlink() {
151            continue;
152        }
153
154        let relative = entry
155            .path()
156            .strip_prefix(src)
157            .map_err(|error| std::io::Error::other(error.to_string()))?;
158        let target = dst.join(relative);
159
160        if entry.file_type().is_dir() {
161            std::fs::create_dir_all(&target)?;
162        } else {
163            if let Some(parent) = target.parent() {
164                std::fs::create_dir_all(parent)?;
165            }
166            std::fs::copy(entry.path(), &target)?;
167        }
168    }
169
170    Ok(())
171}
172
173#[cfg(test)]
174mod wave1_tests {
175    use super::*;
176
177    #[test]
178    fn local_patch_fs_round_trips_and_removes_files() {
179        let temp = tempfile::tempdir().unwrap();
180        let fs = LocalPatchFs::new(temp.path());
181        let relative = Path::new("src/lib.rs");
182        let lines = vec!["fn main() {}".to_string(), "println!(\"ok\");".to_string()];
183
184        fs.write_lines(relative, &lines).unwrap();
185        assert!(fs.exists(relative).unwrap());
186        assert_eq!(fs.read_lines(relative).unwrap(), lines);
187        assert_eq!(fs.snapshot_lines(relative).unwrap(), Some(lines.clone()));
188
189        fs.remove_file(relative).unwrap();
190        assert!(!fs.exists(relative).unwrap());
191        assert_eq!(fs.snapshot_lines(relative).unwrap(), None);
192    }
193
194    #[test]
195    fn local_patch_fs_rejects_escape_paths() {
196        let temp = tempfile::tempdir().unwrap();
197        let fs = LocalPatchFs::new(temp.path());
198
199        let err = fs
200            .write_lines(Path::new("../escape.txt"), &["nope".to_string()])
201            .unwrap_err();
202        assert!(matches!(err, WorkspaceError::Policy(_)));
203    }
204
205    #[test]
206    fn prepare_workspace_copies_fixture_tree() {
207        let fixture = tempfile::tempdir().unwrap();
208        let nested = fixture.path().join("nested");
209        std::fs::create_dir_all(&nested).unwrap();
210        std::fs::write(nested.join("file.txt"), "hello").unwrap();
211
212        let workspace = prepare_workspace(fixture.path()).unwrap();
213        let copied = workspace.host_path.join("nested/file.txt");
214        assert_eq!(std::fs::read_to_string(&copied).unwrap(), "hello");
215
216        let patched = as_patched_workspace(&workspace);
217        assert_eq!(patched.host_path, workspace.host_path);
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use proptest::prelude::*;
225
226    #[test]
227    fn local_patch_fs_round_trips_file_contents() {
228        let root = tempfile::tempdir().unwrap();
229        let fs = LocalPatchFs::new(root.path());
230        let relative = Path::new("nested/file.txt");
231        let lines = vec!["alpha".to_string(), "beta".to_string()];
232
233        fs.write_lines(relative, &lines).unwrap();
234
235        assert!(fs.exists(relative).unwrap());
236        assert_eq!(fs.read_lines(relative).unwrap(), lines);
237        assert_eq!(fs.snapshot_lines(relative).unwrap(), Some(lines.clone()));
238
239        fs.remove_file(relative).unwrap();
240
241        assert!(!fs.exists(relative).unwrap());
242        assert_eq!(fs.snapshot_lines(relative).unwrap(), None);
243    }
244
245    #[test]
246    fn local_patch_fs_rejects_escape_paths() {
247        let root = tempfile::tempdir().unwrap();
248        let fs = LocalPatchFs::new(root.path());
249        let error = fs
250            .write_lines(Path::new("../escape.txt"), &[String::from("nope")])
251            .unwrap_err();
252
253        assert!(matches!(error, WorkspaceError::Policy(_)));
254        assert!(!root.path().join("../escape.txt").exists());
255    }
256
257    #[test]
258    fn prepare_workspace_copies_fixture_recursively() {
259        let fixture = tempfile::tempdir().unwrap();
260        let nested = fixture.path().join("src");
261        std::fs::create_dir_all(&nested).unwrap();
262        std::fs::write(nested.join("lib.rs"), "pub fn demo() {}\n").unwrap();
263
264        let workspace = prepare_workspace(fixture.path()).unwrap();
265        let copied = workspace.host_path.join("src/lib.rs");
266        let patched = as_patched_workspace(&workspace);
267
268        assert_eq!(patched.host_path, workspace.host_path);
269        assert_eq!(
270            std::fs::read_to_string(copied).unwrap(),
271            "pub fn demo() {}\n"
272        );
273    }
274
275    #[cfg(unix)]
276    #[test]
277    fn local_patch_fs_rejects_reads_and_writes_through_symlink_segments() {
278        let root = tempfile::tempdir().unwrap();
279        let outside = tempfile::tempdir().unwrap();
280        std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
281        let fs = LocalPatchFs::new(root.path());
282
283        let read_err = fs.read_lines(Path::new("linked/escape.txt")).unwrap_err();
284        let write_err = fs
285            .write_lines(Path::new("linked/escape.txt"), &[String::from("nope")])
286            .unwrap_err();
287        let remove_err = fs.remove_file(Path::new("linked/escape.txt")).unwrap_err();
288
289        assert!(matches!(read_err, WorkspaceError::Policy(_)));
290        assert!(matches!(write_err, WorkspaceError::Policy(_)));
291        assert!(matches!(remove_err, WorkspaceError::Policy(_)));
292    }
293
294    #[cfg(unix)]
295    #[test]
296    fn prepare_workspace_skips_symlink_entries() {
297        let fixture = tempfile::tempdir().unwrap();
298        let outside = tempfile::tempdir().unwrap();
299        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
300        std::os::unix::fs::symlink(
301            outside.path().join("secret.txt"),
302            fixture.path().join("linked.txt"),
303        )
304        .unwrap();
305
306        let workspace = prepare_workspace(fixture.path()).unwrap();
307        assert!(
308            !workspace.host_path.join("linked.txt").exists(),
309            "symlink entries should not be materialized into the workspace"
310        );
311    }
312
313    #[test]
314    fn create_parent_dirs_rejects_nested_escape_attempts() {
315        let root = tempfile::tempdir().unwrap();
316        let fs = LocalPatchFs::new(root.path());
317
318        let err = fs
319            .create_parent_dirs(Path::new("safe/../../escape.txt"))
320            .unwrap_err();
321        assert!(matches!(err, WorkspaceError::Policy(_)));
322    }
323
324    proptest! {
325        #[test]
326        fn local_patch_fs_round_trips_safe_relative_paths(
327            file_tail in "[A-Za-z0-9_/-]{1,24}",
328            line_a in "[A-Za-z0-9_ (){};]{1,24}",
329            line_b in "[A-Za-z0-9_ (){};]{1,24}",
330        ) {
331            prop_assume!(!file_tail.starts_with('/'));
332            prop_assume!(!file_tail.contains(".."));
333            let root = tempfile::tempdir().unwrap();
334            let fs = LocalPatchFs::new(root.path());
335            let relative = PathBuf::from(format!("nested/{file_tail}.txt"));
336            let lines = vec![line_a, line_b];
337
338            fs.write_lines(&relative, &lines).unwrap();
339            prop_assert_eq!(fs.read_lines(&relative).unwrap(), lines.clone());
340            prop_assert_eq!(fs.snapshot_lines(&relative).unwrap(), Some(lines));
341        }
342
343        #[test]
344        fn local_patch_fs_rejects_generated_escape_paths(path_tail in "[A-Za-z0-9_./-]{1,24}") {
345            let root = tempfile::tempdir().unwrap();
346            let fs = LocalPatchFs::new(root.path());
347            let candidate = PathBuf::from(format!("../{path_tail}"));
348
349            prop_assert!(matches!(
350                fs.write_lines(&candidate, &[String::from("blocked")]),
351                Err(WorkspaceError::Policy(_))
352            ));
353            prop_assert!(matches!(
354                fs.create_parent_dirs(&candidate),
355                Err(WorkspaceError::Policy(_))
356            ));
357        }
358    }
359}