Skip to main content

codebridge_cli/
script_executor.rs

1// script_executor.rs
2use crate::{config::AppConfig, models::ActionResult};
3use std::path::Path;
4use std::time::Duration;
5use tokio::process::Command;
6use tokio::time::timeout;
7use tracing::{debug, error, info, warn};
8
9/// Supported scripting languages.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum ScriptLanguage {
12    Deno,
13    Python,
14    Bash,
15}
16#[allow(clippy::should_implement_trait)]
17impl ScriptLanguage {
18    /// Convert string to ScriptLanguage.
19    pub fn from_str(s: &str) -> Option<Self> {
20        match s {
21            "deno" => Some(ScriptLanguage::Deno),
22            "python" => Some(ScriptLanguage::Python),
23            "bash" => Some(ScriptLanguage::Bash),
24            _ => None,
25        }
26    }
27}
28
29/// Execute a script with the given language, code, arguments, and config.
30pub async fn execute_script(
31    language: ScriptLanguage,
32    code: &str,
33    args: &[String],
34    project_root: &Path,
35    config: &AppConfig,
36) -> ActionResult {
37    // Check if script execution is enabled globally.
38    if !config.script_execution_enabled {
39        return ActionResult {
40            success: false,
41            path: None,
42            content: None,
43            error: Some("Script execution is disabled by configuration".to_string()),
44        };
45    }
46
47    // Prepare the command based on language.
48    let mut cmd = match language {
49        ScriptLanguage::Deno => build_deno_command(code, args, project_root, config),
50        ScriptLanguage::Python => build_python_command(code, args, project_root, config),
51        ScriptLanguage::Bash => build_bash_command(code, args, project_root, config),
52    };
53
54    // Set timeout.
55    let timeout_secs = config.script_timeout_secs;
56    debug!("Executing script with timeout {}s", timeout_secs);
57
58    // Spawn and wait with timeout.
59    let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
60
61    match result {
62        Ok(Ok(output)) => {
63            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
64            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
65            let combined = format!("STDOUT:\n{}\nSTDERR:\n{}", stdout, stderr);
66            let success = output.status.success();
67            if success {
68                info!("Script executed successfully");
69                ActionResult {
70                    success: true,
71                    path: None,
72                    content: Some(combined),
73                    error: None,
74                }
75            } else {
76                warn!("Script failed with exit code: {:?}", output.status.code());
77                ActionResult {
78                    success: false,
79                    path: None,
80                    content: Some(combined),
81                    error: Some(format!("Script exited with status: {}", output.status)),
82                }
83            }
84        }
85        Ok(Err(e)) => {
86            error!("Failed to execute script: {}", e);
87            ActionResult {
88                success: false,
89                content: None,
90                path: None,
91                error: Some(format!("Failed to execute script: {}", e)),
92            }
93        }
94        Err(_) => {
95            warn!("Script execution timed out after {} seconds", timeout_secs);
96            ActionResult {
97                success: false,
98                content: None,
99                path: None,
100                error: Some(format!(
101                    "Script execution timed out after {}s",
102                    timeout_secs
103                )),
104            }
105        }
106    }
107}
108
109fn build_deno_command(
110    code: &str,
111    args: &[String],
112    project_root: &Path,
113    config: &AppConfig,
114) -> Command {
115    let mut cmd = Command::new("deno");
116    cmd.arg("eval").arg(code).current_dir(project_root);
117
118    // Security: restrict filesystem to project root only.
119    if !config.script_enable_network {
120        cmd.arg("--allow-net=none");
121    }
122    cmd.arg(format!("--allow-read={}", project_root.display()))
123        .arg(format!("--allow-write={}", project_root.display()));
124
125    cmd.args(args);
126    cmd
127}
128
129fn build_python_command(
130    code: &str,
131    args: &[String],
132    project_root: &Path,
133    _config: &AppConfig,
134) -> Command {
135    let mut cmd = Command::new("python3");
136    cmd.arg("-c").arg(code).current_dir(project_root);
137    cmd.args(args);
138    cmd
139}
140
141fn build_bash_command(
142    code: &str,
143    args: &[String],
144    project_root: &Path,
145    _config: &AppConfig,
146) -> Command {
147    let mut cmd = Command::new("bash");
148    cmd.arg("-c")
149        .arg(code)
150        .current_dir(project_root)
151        .arg("bash"); // $0
152    cmd.args(args);
153    cmd
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::path::PathBuf;
160    use std::process::Stdio;
161    use tempfile::tempdir;
162
163    // Helper to create a test config with default safe values.
164    fn test_config() -> AppConfig {
165        AppConfig {
166            port: 3000,
167            workspace: PathBuf::from("/tmp"),
168            build_command: "".to_string(),
169            allowed_commands: vec![],
170            script_execution_enabled: true,
171            script_allowed_languages: vec![
172                "deno".to_string(),
173                "python".to_string(),
174                "bash".to_string(),
175            ],
176            script_timeout_secs: 5,
177            script_enable_network: false,
178            enable_worktrees: false,
179            worktrees_dir: PathBuf::from("/tmp"),
180        }
181    }
182
183    // Improved command check: runs the command with --version and verifies success.
184    fn command_works(cmd: &str) -> bool {
185        std::process::Command::new(cmd)
186            .arg("--version")
187            .stdout(Stdio::null())
188            .stderr(Stdio::null())
189            .status()
190            .map(|status| status.success())
191            .unwrap_or(false)
192    }
193
194    #[test]
195    fn test_from_str() {
196        assert_eq!(ScriptLanguage::from_str("deno"), Some(ScriptLanguage::Deno));
197        assert_eq!(
198            ScriptLanguage::from_str("python"),
199            Some(ScriptLanguage::Python)
200        );
201        assert_eq!(ScriptLanguage::from_str("bash"), Some(ScriptLanguage::Bash));
202        assert_eq!(ScriptLanguage::from_str("ruby"), None);
203        assert_eq!(ScriptLanguage::from_str(""), None);
204    }
205
206    #[tokio::test]
207    async fn test_execute_script_disabled() {
208        let mut config = test_config();
209        config.script_execution_enabled = false;
210        let dir = tempdir().unwrap();
211        let result =
212            execute_script(ScriptLanguage::Bash, "echo hi", &[], dir.path(), &config).await;
213        assert!(!result.success);
214        assert!(result.error.unwrap().contains("disabled"));
215    }
216
217    #[tokio::test]
218    async fn test_bash_success() {
219        if !command_works("bash") {
220            return;
221        }
222        let config = test_config();
223        let dir = tempdir().unwrap();
224        let result =
225            execute_script(ScriptLanguage::Bash, "echo hello", &[], dir.path(), &config).await;
226        assert!(result.success, "Failed: {:?}", result.error);
227        let content = result.content.unwrap();
228        assert!(content.contains("hello"));
229    }
230
231    #[tokio::test]
232    async fn test_python_success() {
233        if !command_works("python3") && !command_works("python") {
234            return;
235        }
236        let config = test_config();
237        let dir = tempdir().unwrap();
238        let result = execute_script(
239            ScriptLanguage::Python,
240            "print('hello')",
241            &[],
242            dir.path(),
243            &config,
244        )
245        .await;
246        assert!(result.success, "Failed: {:?}", result.error);
247        let content = result.content.unwrap();
248        assert!(content.contains("hello"));
249    }
250
251    // Deno tests: only run if deno works.
252    async fn deno_works() -> bool {
253        if !command_works("deno") {
254            return false;
255        }
256        // Try a simple eval to ensure it works with our flags.
257        let config = test_config();
258        let dir = tempdir().unwrap();
259        let result = execute_script(
260            ScriptLanguage::Deno,
261            "console.log('test')",
262            &[],
263            dir.path(),
264            &config,
265        )
266        .await;
267        result.success
268    }
269
270    #[tokio::test]
271    async fn test_deno_success() {
272        if !deno_works().await {
273            return;
274        }
275        let config = test_config();
276        let dir = tempdir().unwrap();
277        let result = execute_script(
278            ScriptLanguage::Deno,
279            "console.log('hello')",
280            &[],
281            dir.path(),
282            &config,
283        )
284        .await;
285        assert!(
286            result.success,
287            "Deno failed: {:?}\nOutput: {:?}",
288            result.error, result.content
289        );
290        let content = result.content.unwrap();
291        assert!(content.contains("hello"));
292    }
293
294    #[tokio::test]
295    async fn test_with_args() {
296        if !command_works("bash") {
297            return;
298        }
299        let config = test_config();
300        let dir = tempdir().unwrap();
301        let result = execute_script(
302            ScriptLanguage::Bash,
303            "echo $1",
304            &["arg1".to_string()],
305            dir.path(),
306            &config,
307        )
308        .await;
309        assert!(result.success);
310        assert!(result.content.unwrap().contains("arg1"));
311    }
312
313    #[tokio::test]
314    async fn test_timeout() {
315        if !command_works("bash") {
316            return;
317        }
318        let mut config = test_config();
319        config.script_timeout_secs = 1; // 1 second timeout
320        let dir = tempdir().unwrap();
321        let result =
322            execute_script(ScriptLanguage::Bash, "sleep 3", &[], dir.path(), &config).await;
323        assert!(!result.success);
324        let err = result.error.unwrap();
325        assert!(err.contains("timed out") || err.contains("Timeout"));
326    }
327
328    #[tokio::test]
329    async fn test_failure_exit_code() {
330        if !command_works("bash") {
331            return;
332        }
333        let config = test_config();
334        let dir = tempdir().unwrap();
335        let result = execute_script(ScriptLanguage::Bash, "exit 1", &[], dir.path(), &config).await;
336        assert!(!result.success);
337        let err = result.error.unwrap();
338        assert!(err.contains("exited with status"));
339    }
340
341    #[tokio::test]
342    async fn test_deno_network_disabled() {
343        if !deno_works().await {
344            return;
345        }
346        let mut config = test_config();
347        config.script_enable_network = false;
348        let dir = tempdir().unwrap();
349        // Try to fetch a URL – should fail because network is disabled.
350        let result = execute_script(
351            ScriptLanguage::Deno,
352            "await fetch('https://example.com').then(r => r.status)",
353            &[],
354            dir.path(),
355            &config,
356        )
357        .await;
358        // It should fail with a network error.
359        assert!(!result.success, "Expected failure but got success");
360        let err = result.error.unwrap();
361        // The error message may vary; we just check it's not empty.
362        assert!(!err.is_empty());
363    }
364
365    #[tokio::test]
366    async fn test_deno_network_enabled() {
367        if !deno_works().await {
368            return;
369        }
370        let mut config = test_config();
371        config.script_enable_network = true;
372        let dir = tempdir().unwrap();
373        // Simple fetch that should succeed if network is allowed and online.
374        let result = execute_script(
375            ScriptLanguage::Deno,
376            "console.log(await fetch('https://example.com').then(r => r.status))",
377            &[],
378            dir.path(),
379            &config,
380        )
381        .await;
382        if result.success {
383            let content = result.content.unwrap();
384            assert!(content.contains("200"));
385        } else {
386            // If it fails, it might be due to network issues; we don't want to fail the test.
387            println!(
388                "Deno network test skipped due to network issue: {:?}",
389                result.error
390            );
391        }
392    }
393}