Files
storkit/server/src/io/shell.rs

110 lines
3.3 KiB
Rust
Raw Normal View History

use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
use crate::state::SessionState;
use serde::Serialize;
use std::fs;
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()
}
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
let approved = tokio::task::spawn_blocking(move || {
let story_path = root
.join(".story_kit")
.join("stories")
.join("current")
.join("26_establish_tdd_workflow_and_gates.md");
let contents = fs::read_to_string(&story_path)
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
})
.await
.map_err(|e| format!("Task failed: {e}"))??;
if approved {
Ok(())
} else {
Err("Test plan is not approved for the current story.".to_string())
}
}
#[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)?;
ensure_test_plan_approved(root.clone()).await?;
exec_shell_impl(command, args, root).await
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn exec_shell_requires_approved_test_plan() {
let dir = tempdir().expect("tempdir");
let state = SessionState::default();
{
let mut root = state.project_root.lock().expect("lock project root");
*root = Some(dir.path().to_path_buf());
}
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
assert!(
result.is_err(),
"expected shell execution to be blocked when test plan is not approved"
);
}
}