2026-02-13 12:31:36 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
/// Helper to get the root path (cloned) without joining
|
|
|
|
|
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
|
|
|
|
state.get_project_root()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Debug, poem_openapi::Object)]
|
|
|
|
|
pub struct CommandOutput {
|
|
|
|
|
pub stdout: String,
|
|
|
|
|
pub stderr: String,
|
|
|
|
|
pub exit_code: i32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Execute shell command logic (pure function for testing)
|
|
|
|
|
async fn exec_shell_impl(
|
|
|
|
|
command: String,
|
|
|
|
|
args: Vec<String>,
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
) -> Result<CommandOutput, String> {
|
|
|
|
|
// Security Allowlist
|
|
|
|
|
let allowed_commands = [
|
|
|
|
|
"git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm",
|
|
|
|
|
"mv", "cp", "touch", "rustc", "rustfmt",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if !allowed_commands.contains(&command.as_str()) {
|
|
|
|
|
return Err(format!("Command '{}' is not in the allowlist.", command));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let output = tokio::task::spawn_blocking(move || {
|
|
|
|
|
Command::new(&command)
|
|
|
|
|
.args(&args)
|
|
|
|
|
.current_dir(root)
|
|
|
|
|
.output()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
|
|
|
.map_err(|e| format!("Failed to execute command: {}", e))?;
|
|
|
|
|
|
|
|
|
|
Ok(CommandOutput {
|
|
|
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
|
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
|
|
|
exit_code: output.status.code().unwrap_or(-1),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn exec_shell(
|
|
|
|
|
command: String,
|
|
|
|
|
args: Vec<String>,
|
|
|
|
|
state: &SessionState,
|
|
|
|
|
) -> Result<CommandOutput, String> {
|
|
|
|
|
let root = get_project_root(state)?;
|
|
|
|
|
exec_shell_impl(command, args, root).await
|
|
|
|
|
}
|
2026-02-19 12:54:04 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn exec_shell_impl_rejects_disallowed_command() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let result = exec_shell_impl(
|
|
|
|
|
"curl".to_string(),
|
|
|
|
|
vec!["https://example.com".to_string()],
|
|
|
|
|
dir.path().to_path_buf(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(result.unwrap_err().contains("not in the allowlist"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn exec_shell_impl_runs_allowed_command() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let result = exec_shell_impl(
|
|
|
|
|
"ls".to_string(),
|
|
|
|
|
Vec::new(),
|
|
|
|
|
dir.path().to_path_buf(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let output = result.unwrap();
|
|
|
|
|
assert_eq!(output.exit_code, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn exec_shell_impl_captures_stdout() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
std::fs::write(dir.path().join("hello.txt"), "").unwrap();
|
|
|
|
|
|
|
|
|
|
let result = exec_shell_impl(
|
|
|
|
|
"ls".to_string(),
|
|
|
|
|
Vec::new(),
|
|
|
|
|
dir.path().to_path_buf(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(result.stdout.contains("hello.txt"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn exec_shell_impl_returns_nonzero_exit_code() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let result = exec_shell_impl(
|
|
|
|
|
"ls".to_string(),
|
|
|
|
|
vec!["nonexistent_file_xyz".to_string()],
|
|
|
|
|
dir.path().to_path_buf(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_ne!(result.exit_code, 0);
|
|
|
|
|
assert!(!result.stderr.is_empty());
|
|
|
|
|
}
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|