Skip to main content

aiw/
sandbox.rs

1use std::path::{Path, PathBuf};
2use std::process::{Command, ExitStatus};
3
4#[cfg(not(test))]
5use crate::config::ConfigInitializer;
6use crate::tools::FenceCommand;
7use crate::workspace::Workspace;
8
9#[derive(Debug, thiserror::Error)]
10pub enum SandboxError {
11    #[error("Fence executable 'fence' not found in PATH")]
12    FenceNotFound,
13    #[error("Invalid argument ordering: {0}")]
14    InvalidArgOrder(String),
15    #[error("Sandbox command failed with exit status: {0}")]
16    ExecutionFailed(ExitStatus),
17    #[error("I/O error during sandbox execution: {0}")]
18    Io(#[from] std::io::Error),
19}
20
21#[derive(Debug)]
22pub struct SandboxBuilder<'a> {
23    workspace_path: &'a Path,
24    repo_root: &'a Path,
25    command: &'a [String],
26    settings_path: Option<&'a Path>,
27    use_direnv: bool,
28    #[cfg(test)]
29    shared_config_dir: Option<PathBuf>,
30}
31
32impl<'a> SandboxBuilder<'a> {
33    /// Creates a new `SandboxBuilder` for the specified workspace, repository root, and required payload command.
34    #[must_use]
35    pub fn new(workspace_path: &'a Path, repo_root: &'a Path, command: &'a [String]) -> Self {
36        Self {
37            workspace_path,
38            repo_root,
39            command,
40            settings_path: None,
41            use_direnv: false,
42            #[cfg(test)]
43            shared_config_dir: None,
44        }
45    }
46
47    /// Creates a new `SandboxBuilder` configured for the given `Workspace` and required payload command.
48    #[must_use]
49    pub fn for_workspace(workspace: &'a Workspace, command: &'a [String]) -> Self {
50        Self::new(workspace.path(), workspace.repo_root(), command)
51    }
52
53    /// Explicit path to fence settings file (`fence.json` or `fence.jsonc`).
54    #[must_use]
55    pub fn with_settings_path(mut self, settings_path: &'a Path) -> Self {
56        self.settings_path = Some(settings_path);
57        self
58    }
59
60    /// Configures whether `direnv exec .` is prepended to the container payload command.
61    #[must_use]
62    pub fn with_direnv(mut self, use_direnv: bool) -> Self {
63        self.use_direnv = use_direnv;
64        self
65    }
66
67    /// Configures whether `direnv exec .` is prepended to the container payload command.
68    #[must_use]
69    pub fn with_use_direnv(self, use_direnv: bool) -> Self {
70        self.with_direnv(use_direnv)
71    }
72
73    /// Overrides the shared configuration directory for test isolation.
74    #[cfg(test)]
75    #[must_use]
76    pub fn with_shared_config_dir(mut self, dir: PathBuf) -> Self {
77        self.shared_config_dir = Some(dir);
78        self
79    }
80
81    fn append_settings_args(&self, cmd: &mut Command) {
82        if let Some(settings) = self.resolve_settings_path() {
83            cmd.args(["--settings", &settings.to_string_lossy()]);
84        }
85    }
86
87    pub(crate) fn append_direnv_args(&self, cmd: &mut Command) -> Result<(), SandboxError> {
88        if !self.use_direnv {
89            return Ok(());
90        }
91        if cmd.get_args().last() != Some(std::ffi::OsStr::new("--")) {
92            return Err(SandboxError::InvalidArgOrder(
93                "direnv arguments must be appended immediately after '--'".to_string(),
94            ));
95        }
96        cmd.args(["direnv", "exec", "."]);
97        Ok(())
98    }
99
100    fn resolve_settings_path(&self) -> Option<PathBuf> {
101        self.settings_path
102            .filter(|p| p.exists())
103            .map(Path::to_path_buf)
104            .or_else(|| {
105                [
106                    self.workspace_path.join("fence.jsonc"),
107                    self.workspace_path.join("fence.json"),
108                    self.repo_root.join("fence.jsonc"),
109                    self.repo_root.join("fence.json"),
110                ]
111                .into_iter()
112                .find(|p| p.exists())
113            })
114            .or_else(|| {
115                self.shared_template_candidates()
116                    .into_iter()
117                    .find(|p| p.exists())
118            })
119    }
120
121    fn shared_template_candidates(&self) -> Vec<PathBuf> {
122        #[cfg(test)]
123        {
124            self.shared_config_dir
125                .as_ref()
126                .map(|dir| vec![dir.join("fence.jsonc")])
127                .unwrap_or_default()
128        }
129
130        #[cfg(not(test))]
131        {
132            let mut candidates = Vec::new();
133
134            // 1. User configuration directory
135            if let Some(user_config) = ConfigInitializer::user_config_path() {
136                candidates.push(user_config);
137            }
138
139            // 2. System / package data directories ($XDG_DATA_DIRS/.../aiw/fence.jsonc)
140            if let Ok(data_dirs) = std::env::var("XDG_DATA_DIRS") {
141                for data_dir in data_dirs.split(':').filter(|s| !s.is_empty()) {
142                    candidates.push(Path::new(data_dir).join("aiw").join("fence.jsonc"));
143                }
144            }
145
146            candidates.push(PathBuf::from("/usr/local/share/aiw/fence.jsonc"));
147            candidates.push(PathBuf::from("/usr/share/aiw/fence.jsonc"));
148
149            candidates
150        }
151    }
152
153    fn construct_command(&self) -> Result<Command, SandboxError> {
154        let mut cmd = FenceCommand::create(self.workspace_path);
155
156        self.append_settings_args(&mut cmd);
157        cmd.arg("--");
158        self.append_direnv_args(&mut cmd)?;
159        cmd.args(self.command);
160
161        Ok(cmd)
162    }
163
164    /// Builds the `std::process::Command` prepared to execute fence.
165    pub fn build_command(&self) -> Result<Command, SandboxError> {
166        if which::which("fence").is_err() {
167            return Err(SandboxError::FenceNotFound);
168        }
169
170        self.construct_command()
171    }
172
173    /// Executes fence synchronously, forwarding standard I/O.
174    /// Returns `Ok(())` on success, or `Err(SandboxError::ExecutionFailed)` if the command exited with a non-zero status.
175    pub fn run(&self) -> Result<(), SandboxError> {
176        let mut cmd = self.build_command()?;
177        cmd.stdin(std::process::Stdio::inherit())
178            .stdout(std::process::Stdio::inherit())
179            .stderr(std::process::Stdio::inherit());
180        let status = cmd.status()?;
181        if !status.success() {
182            return Err(SandboxError::ExecutionFailed(status));
183        }
184        Ok(())
185    }
186
187    #[cfg(test)]
188    fn build_args(&self) -> Result<Vec<String>, SandboxError> {
189        let cmd = self.build_command()?;
190        Ok(cmd
191            .get_args()
192            .map(|a| a.to_string_lossy().into_owned())
193            .collect())
194    }
195}
196
197impl std::fmt::Display for SandboxBuilder<'_> {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        let cmd = self.construct_command().map_err(|_err| std::fmt::Error)?;
200        write!(f, "{}", cmd.get_program().to_string_lossy())?;
201        for arg in cmd.get_args() {
202            write!(f, " {}", arg.to_string_lossy())?;
203        }
204        Ok(())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use googletest::prelude::*;
212
213    fn contains_subslice<'a, 'b>(expected: &'a [&'a str]) -> impl Matcher<&'b [String]> + 'a {
214        predicate(move |actual: &[String]| {
215            if expected.is_empty() {
216                return true;
217            }
218            actual
219                .windows(expected.len())
220                .any(|window| window.iter().zip(expected.iter()).all(|(a, b)| a == b))
221        })
222        .with_description(
223            format!("contains contiguous subslice {expected:?}"),
224            format!("does not contain contiguous subslice {expected:?}"),
225        )
226    }
227
228    type TestResult = Result<(), Box<dyn std::error::Error>>;
229
230    #[googletest::test]
231    fn build_args_with_existing_fence_json_in_repo_root_passes_settings_flag() -> TestResult {
232        let temp_dir = tempfile::tempdir()?;
233        let repo_root = temp_dir.path().join("repo");
234        let workspace_path = repo_root.join(".workspaces").join("my-ws");
235        std::fs::create_dir_all(&workspace_path)?;
236
237        let fence_json = repo_root.join("fence.json");
238        std::fs::write(&fence_json, r#"{"extends": "code"}"#)?;
239
240        let cmd = vec![
241            "agy".to_string(),
242            "--dangerously-skip-permissions".to_string(),
243        ];
244        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
245        let args = builder.build_args()?;
246
247        let fence_str = fence_json.display().to_string();
248        expect_that!(&args[..], contains_subslice(&["--settings", &fence_str]));
249        expect_that!(
250            &args[..],
251            contains_subslice(&["--", "agy", "--dangerously-skip-permissions"])
252        );
253        Ok(())
254    }
255
256    #[googletest::test]
257    fn build_args_with_fence_jsonc_in_workspace_takes_precedence_over_repo_root() -> TestResult {
258        let temp_dir = tempfile::tempdir()?;
259        let repo_root = temp_dir.path().join("repo");
260        let workspace_path = repo_root.join(".workspaces").join("ws");
261        std::fs::create_dir_all(&workspace_path)?;
262
263        let root_fence = repo_root.join("fence.json");
264        std::fs::write(&root_fence, r#"{"extends": "code"}"#)?;
265        let ws_fence = workspace_path.join("fence.jsonc");
266        std::fs::write(&ws_fence, r#"{"extends": "code"}"#)?;
267
268        let cmd = vec!["agy".to_string()];
269        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
270        let args = builder.build_args()?;
271
272        let ws_fence_str = ws_fence.display().to_string();
273        expect_that!(&args[..], contains_subslice(&["--settings", &ws_fence_str]));
274        Ok(())
275    }
276
277    #[googletest::test]
278    fn build_args_without_any_fence_config_delegates_to_fence_discovery() -> TestResult {
279        let temp_dir = tempfile::tempdir()?;
280        let repo_root = temp_dir.path().join("repo");
281        let workspace_path = repo_root.join(".workspaces").join("ws");
282        std::fs::create_dir_all(&workspace_path)?;
283
284        let cmd = vec![
285            "agy".to_string(),
286            "--dangerously-skip-permissions".to_string(),
287        ];
288        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
289        let args = builder.build_args()?;
290
291        expect_that!(
292            &args[..],
293            elements_are![eq("--"), eq("agy"), eq("--dangerously-skip-permissions")]
294        );
295        Ok(())
296    }
297
298    #[googletest::test]
299    fn build_args_discovers_shared_template_when_no_local_fence_config_exists() -> TestResult {
300        let temp_dir = tempfile::tempdir()?;
301        let repo_root = temp_dir.path().join("repo");
302        let workspace_path = repo_root.join(".workspaces").join("ws");
303        let shared_dir = temp_dir.path().join("shared");
304        std::fs::create_dir_all(&workspace_path)?;
305        std::fs::create_dir_all(&shared_dir)?;
306
307        let shared_template = shared_dir.join("fence.jsonc");
308        std::fs::write(&shared_template, r#"{"extends": "code"}"#)?;
309
310        let cmd = vec!["agy".to_string()];
311        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
312            .with_shared_config_dir(shared_dir.clone());
313        let args = builder.build_args()?;
314
315        let tpl_str = shared_template.display().to_string();
316        expect_that!(&*args, contains_subslice(&["--settings", &tpl_str]));
317        Ok(())
318    }
319
320    #[googletest::test]
321    fn build_args_local_fence_config_takes_precedence_over_shared_template() -> TestResult {
322        let temp_dir = tempfile::tempdir()?;
323        let repo_root = temp_dir.path().join("repo");
324        let workspace_path = repo_root.join(".workspaces").join("ws");
325        let shared_dir = temp_dir.path().join("shared");
326        std::fs::create_dir_all(&workspace_path)?;
327        std::fs::create_dir_all(&shared_dir)?;
328
329        let shared_template = shared_dir.join("fence.jsonc");
330        std::fs::write(&shared_template, r#"{"extends": "code"}"#)?;
331
332        let local_fence = workspace_path.join("fence.jsonc");
333        std::fs::write(&local_fence, r#"{"extends": "code"}"#)?;
334
335        let cmd = vec!["agy".to_string()];
336        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
337            .with_shared_config_dir(shared_dir);
338        let args = builder.build_args()?;
339
340        let local_str = local_fence.display().to_string();
341        expect_that!(&*args, contains_subslice(&["--settings", &local_str]));
342        Ok(())
343    }
344
345    #[googletest::test]
346    fn build_args_with_explicit_settings_path_uses_specified_path() -> TestResult {
347        let temp_dir = tempfile::tempdir()?;
348        let repo_root = temp_dir.path().join("repo");
349        let workspace_path = repo_root.join(".workspaces").join("ws");
350        std::fs::create_dir_all(&workspace_path)?;
351
352        let custom_fence = temp_dir.path().join("custom-fence.json");
353        std::fs::write(&custom_fence, r#"{"extends": "code"}"#)?;
354
355        let cmd = vec!["agy".to_string()];
356        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
357            .with_settings_path(&custom_fence);
358        let args = builder.build_args()?;
359
360        let custom_str = custom_fence.display().to_string();
361        expect_that!(&args[..], contains_subslice(&["--settings", &custom_str]));
362        Ok(())
363    }
364
365    #[googletest::test]
366    fn build_args_with_use_direnv_true_prepends_direnv_exec() -> TestResult {
367        let temp_dir = tempfile::tempdir()?;
368        let repo_root = temp_dir.path().join("repo");
369        let workspace_path = repo_root.join(".workspaces").join("ws");
370        std::fs::create_dir_all(&workspace_path)?;
371
372        let cmd = vec![
373            "agy".to_string(),
374            "--dangerously-skip-permissions".to_string(),
375        ];
376        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd).with_direnv(true);
377        let args = builder.build_args()?;
378
379        expect_that!(
380            &args[..],
381            contains_subslice(&[
382                "--",
383                "direnv",
384                "exec",
385                ".",
386                "agy",
387                "--dangerously-skip-permissions"
388            ])
389        );
390        Ok(())
391    }
392
393    #[googletest::test]
394    fn build_args_with_custom_command_executes_specified_command() -> TestResult {
395        let temp_dir = tempfile::tempdir()?;
396        let repo_root = temp_dir.path().join("repo");
397        let workspace_path = repo_root.join(".workspaces").join("ws");
398        std::fs::create_dir_all(&workspace_path)?;
399
400        let custom_cmd = vec!["sh".to_string(), "-c".to_string(), "echo ok".to_string()];
401        let builder =
402            SandboxBuilder::new(&workspace_path, &repo_root, &custom_cmd).with_direnv(true);
403        let args = builder.build_args()?;
404
405        expect_that!(
406            &args[..],
407            contains_subslice(&["--", "direnv", "exec", ".", "sh", "-c", "echo ok"])
408        );
409        Ok(())
410    }
411
412    #[googletest::test]
413    fn build_command_sets_fence_program_and_workspace_cwd() -> TestResult {
414        let temp_dir = tempfile::tempdir()?;
415        let repo_root = temp_dir.path().join("repo");
416        let workspace_path = repo_root.join(".workspaces").join("ws");
417        std::fs::create_dir_all(&workspace_path)?;
418
419        let cmd_payload = vec!["agy".to_string()];
420        let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd_payload);
421        let cmd = builder.build_command()?;
422
423        expect_that!(cmd.get_program(), eq("fence"));
424        expect_that!(cmd.get_current_dir(), some(eq(&workspace_path)));
425        Ok(())
426    }
427
428    #[googletest::test]
429    fn append_direnv_args_called_out_of_order_returns_error() -> TestResult {
430        let temp_dir = tempfile::tempdir()?;
431        let repo_root = temp_dir.path();
432        let ws_path = repo_root.join("ws");
433        let cmd_payload = vec!["echo".to_string()];
434
435        let builder = SandboxBuilder::new(&ws_path, repo_root, &cmd_payload).with_direnv(true);
436
437        // Case 1: called on Command without "--" separator
438        let mut cmd1 = Command::new("fence");
439        let res1 = builder.append_direnv_args(&mut cmd1);
440        expect_that!(
441            res1,
442            matches_pattern!(Err(matches_pattern!(SandboxError::InvalidArgOrder(
443                anything()
444            ))))
445        );
446
447        // Case 2: called after command arguments already present
448        let mut cmd2 = Command::new("fence");
449        cmd2.arg("--");
450        cmd2.arg("echo");
451        let res2 = builder.append_direnv_args(&mut cmd2);
452        expect_that!(
453            res2,
454            matches_pattern!(Err(matches_pattern!(SandboxError::InvalidArgOrder(
455                anything()
456            ))))
457        );
458
459        // Case 3: called immediately after "--" succeeds
460        let mut cmd3 = Command::new("fence");
461        cmd3.arg("--");
462        let res3 = builder.append_direnv_args(&mut cmd3);
463        expect_that!(res3, ok(anything()));
464        Ok(())
465    }
466
467    #[googletest::test]
468    fn display_trait_produces_readable_shell_string() -> TestResult {
469        let temp_dir = tempfile::tempdir()?;
470        let repo_root = temp_dir.path().join("repo");
471        let workspace_path = repo_root.join(".workspaces").join("ws");
472        std::fs::create_dir_all(&workspace_path)?;
473
474        let custom_cmd = vec!["sh".to_string(), "-c".to_string(), "echo hi".to_string()];
475        let builder =
476            SandboxBuilder::new(&workspace_path, &repo_root, &custom_cmd).with_direnv(true);
477
478        let formatted = format!("{builder}");
479        expect_that!(&formatted, eq("fence -- direnv exec . sh -c echo hi"));
480        expect_that!(&builder.to_string(), eq(&formatted));
481        Ok(())
482    }
483
484    #[googletest::test]
485    fn for_workspace_configures_workspace_cwd_and_preserves_command() -> TestResult {
486        let temp_dir = tempfile::tempdir()?;
487        let repo_root = temp_dir.path();
488        let output = std::process::Command::new("jj")
489            .args(["--no-pager", "git", "init"])
490            .arg(repo_root)
491            .output()?;
492        expect_that!(output.status.success(), is_true());
493
494        let ws = Workspace::new(repo_root, "builder-ws")?;
495        let cmd_payload = vec![
496            "agy".to_string(),
497            "--dangerously-skip-permissions".to_string(),
498            "--extra-flag".to_string(),
499        ];
500        let builder = SandboxBuilder::for_workspace(&ws, &cmd_payload).with_direnv(true);
501
502        let cmd = builder.build_command()?;
503        expect_that!(cmd.get_current_dir(), some(eq(ws.path())));
504        let formatted = format!("{builder}");
505        expect_that!(
506            &formatted,
507            contains_substring("direnv exec . agy --dangerously-skip-permissions --extra-flag")
508        );
509        Ok(())
510    }
511}