use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GrepMatch {
pub path: String,
pub line_number: usize,
pub line_content: String,
pub match_start: usize,
pub match_end: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl ExecResult {
#[must_use]
pub const fn success(&self) -> bool {
self.exit_code == 0
}
}
#[async_trait]
pub trait Environment: Send + Sync {
async fn read_file(&self, path: &str) -> Result<String>;
async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
async fn write_file(&self, path: &str, content: &str) -> Result<()>;
async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()>;
async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>>;
async fn exists(&self, path: &str) -> Result<bool>;
async fn is_dir(&self, path: &str) -> Result<bool>;
async fn is_file(&self, path: &str) -> Result<bool>;
async fn create_dir(&self, path: &str) -> Result<()>;
async fn delete_file(&self, path: &str) -> Result<()>;
async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()>;
async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>>;
async fn glob(&self, pattern: &str) -> Result<Vec<String>>;
async fn exec(&self, _command: &str, _timeout_ms: Option<u64>) -> Result<ExecResult> {
anyhow::bail!("Command execution not supported in this environment")
}
fn root(&self) -> &str;
fn resolve_path(&self, path: &str) -> String {
let joined = if path.starts_with('/') {
PathBuf::from(path)
} else {
PathBuf::from(self.root()).join(path)
};
normalize_path(&joined)
}
}
pub fn normalize_path(path: &Path) -> String {
normalize_path_buf(path).to_string_lossy().into_owned()
}
pub fn normalize_path_buf(path: &Path) -> PathBuf {
let mut components: Vec<Component<'_>> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if matches!(components.last(), Some(Component::Normal(_))) {
components.pop();
}
}
Component::CurDir => {} other => components.push(other),
}
}
if components.is_empty() {
PathBuf::from("/")
} else {
components.iter().collect()
}
}
pub struct NullEnvironment;
#[async_trait]
impl Environment for NullEnvironment {
async fn read_file(&self, _path: &str) -> Result<String> {
anyhow::bail!("No environment configured")
}
async fn read_file_bytes(&self, _path: &str) -> Result<Vec<u8>> {
anyhow::bail!("No environment configured")
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn write_file_bytes(&self, _path: &str, _content: &[u8]) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn list_dir(&self, _path: &str) -> Result<Vec<FileEntry>> {
anyhow::bail!("No environment configured")
}
async fn exists(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn is_dir(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn is_file(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn create_dir(&self, _path: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn delete_file(&self, _path: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn delete_dir(&self, _path: &str, _recursive: bool) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn grep(&self, _pattern: &str, _path: &str, _recursive: bool) -> Result<Vec<GrepMatch>> {
anyhow::bail!("No environment configured")
}
async fn glob(&self, _pattern: &str) -> Result<Vec<String>> {
anyhow::bail!("No environment configured")
}
fn root(&self) -> &'static str {
"/"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_resolves_parent_dir() {
let path = Path::new("/workspace/src/../../etc/passwd");
assert_eq!(normalize_path(path), "/etc/passwd");
}
#[test]
fn test_normalize_path_resolves_current_dir() {
let path = Path::new("/workspace/./src/./file.rs");
assert_eq!(normalize_path(path), "/workspace/src/file.rs");
}
#[test]
fn test_normalize_path_does_not_escape_root() {
let path = Path::new("/workspace/../../../etc/shadow");
assert_eq!(normalize_path(path), "/etc/shadow");
}
#[test]
fn test_normalize_path_identity() {
let path = Path::new("/workspace/src/main.rs");
assert_eq!(normalize_path(path), "/workspace/src/main.rs");
}
#[test]
fn test_normalize_path_clamps_at_root() {
let path = Path::new("/a/../../../../z");
assert_eq!(normalize_path(path), "/z");
}
#[test]
fn test_resolve_path_normalizes_traversal() {
let env = NullEnvironment;
let resolved = env.resolve_path("src/../../etc/passwd");
assert_eq!(resolved, "/etc/passwd");
}
#[test]
fn test_resolve_path_absolute_normalized() {
let env = NullEnvironment;
let resolved = env.resolve_path("/workspace/src/../../../etc/passwd");
assert_eq!(resolved, "/etc/passwd");
}
}