1use 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}