1use anyhow::Result;
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use std::path::{Component, Path, PathBuf};
5
6#[derive(Clone, Debug, Serialize, Deserialize)]
8pub struct FileEntry {
9 pub name: String,
10 pub path: String,
11 pub is_dir: bool,
12 pub size: Option<u64>,
13}
14
15#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct GrepMatch {
18 pub path: String,
19 pub line_number: usize,
20 pub line_content: String,
21 pub match_start: usize,
22 pub match_end: usize,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct ExecResult {
28 pub stdout: String,
29 pub stderr: String,
30 pub exit_code: i32,
31}
32
33impl ExecResult {
34 #[must_use]
35 pub const fn success(&self) -> bool {
36 self.exit_code == 0
37 }
38}
39
40#[async_trait]
50pub trait Environment: Send + Sync {
51 async fn read_file(&self, path: &str) -> Result<String>;
56
57 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
62
63 async fn write_file(&self, path: &str, content: &str) -> Result<()>;
68
69 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()>;
74
75 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>>;
80
81 async fn exists(&self, path: &str) -> Result<bool>;
86
87 async fn is_dir(&self, path: &str) -> Result<bool>;
92
93 async fn is_file(&self, path: &str) -> Result<bool>;
98
99 async fn create_dir(&self, path: &str) -> Result<()>;
104
105 async fn delete_file(&self, path: &str) -> Result<()>;
110
111 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()>;
116
117 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>>;
122
123 async fn glob(&self, pattern: &str) -> Result<Vec<String>>;
128
129 async fn exec(&self, _command: &str, _timeout_ms: Option<u64>) -> Result<ExecResult> {
136 anyhow::bail!("Command execution not supported in this environment")
137 }
138
139 fn root(&self) -> &str;
141
142 fn resolve_path(&self, path: &str) -> String {
146 let joined = if path.starts_with('/') {
147 PathBuf::from(path)
148 } else {
149 PathBuf::from(self.root()).join(path)
150 };
151 normalize_path(&joined)
152 }
153}
154
155pub fn normalize_path(path: &Path) -> String {
162 normalize_path_buf(path).to_string_lossy().into_owned()
163}
164
165pub fn normalize_path_buf(path: &Path) -> PathBuf {
167 let mut components: Vec<Component<'_>> = Vec::new();
168 for component in path.components() {
169 match component {
170 Component::ParentDir => {
171 if matches!(components.last(), Some(Component::Normal(_))) {
173 components.pop();
174 }
175 }
176 Component::CurDir => {} other => components.push(other),
178 }
179 }
180 if components.is_empty() {
181 PathBuf::from("/")
182 } else {
183 components.iter().collect()
184 }
185}
186
187pub struct NullEnvironment;
190
191#[async_trait]
192impl Environment for NullEnvironment {
193 async fn read_file(&self, _path: &str) -> Result<String> {
194 anyhow::bail!("No environment configured")
195 }
196
197 async fn read_file_bytes(&self, _path: &str) -> Result<Vec<u8>> {
198 anyhow::bail!("No environment configured")
199 }
200
201 async fn write_file(&self, _path: &str, _content: &str) -> Result<()> {
202 anyhow::bail!("No environment configured")
203 }
204
205 async fn write_file_bytes(&self, _path: &str, _content: &[u8]) -> Result<()> {
206 anyhow::bail!("No environment configured")
207 }
208
209 async fn list_dir(&self, _path: &str) -> Result<Vec<FileEntry>> {
210 anyhow::bail!("No environment configured")
211 }
212
213 async fn exists(&self, _path: &str) -> Result<bool> {
214 anyhow::bail!("No environment configured")
215 }
216
217 async fn is_dir(&self, _path: &str) -> Result<bool> {
218 anyhow::bail!("No environment configured")
219 }
220
221 async fn is_file(&self, _path: &str) -> Result<bool> {
222 anyhow::bail!("No environment configured")
223 }
224
225 async fn create_dir(&self, _path: &str) -> Result<()> {
226 anyhow::bail!("No environment configured")
227 }
228
229 async fn delete_file(&self, _path: &str) -> Result<()> {
230 anyhow::bail!("No environment configured")
231 }
232
233 async fn delete_dir(&self, _path: &str, _recursive: bool) -> Result<()> {
234 anyhow::bail!("No environment configured")
235 }
236
237 async fn grep(&self, _pattern: &str, _path: &str, _recursive: bool) -> Result<Vec<GrepMatch>> {
238 anyhow::bail!("No environment configured")
239 }
240
241 async fn glob(&self, _pattern: &str) -> Result<Vec<String>> {
242 anyhow::bail!("No environment configured")
243 }
244
245 fn root(&self) -> &'static str {
246 "/"
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_normalize_path_resolves_parent_dir() {
256 let path = Path::new("/workspace/src/../../etc/passwd");
257 assert_eq!(normalize_path(path), "/etc/passwd");
258 }
259
260 #[test]
261 fn test_normalize_path_resolves_current_dir() {
262 let path = Path::new("/workspace/./src/./file.rs");
263 assert_eq!(normalize_path(path), "/workspace/src/file.rs");
264 }
265
266 #[test]
267 fn test_normalize_path_does_not_escape_root() {
268 let path = Path::new("/workspace/../../../etc/shadow");
269 assert_eq!(normalize_path(path), "/etc/shadow");
270 }
271
272 #[test]
273 fn test_normalize_path_identity() {
274 let path = Path::new("/workspace/src/main.rs");
275 assert_eq!(normalize_path(path), "/workspace/src/main.rs");
276 }
277
278 #[test]
279 fn test_normalize_path_clamps_at_root() {
280 let path = Path::new("/a/../../../../z");
282 assert_eq!(normalize_path(path), "/z");
283 }
284
285 #[test]
286 fn test_resolve_path_normalizes_traversal() {
287 let env = NullEnvironment;
288 let resolved = env.resolve_path("src/../../etc/passwd");
290 assert_eq!(resolved, "/etc/passwd");
291 }
292
293 #[test]
294 fn test_resolve_path_absolute_normalized() {
295 let env = NullEnvironment;
296 let resolved = env.resolve_path("/workspace/src/../../../etc/passwd");
297 assert_eq!(resolved, "/etc/passwd");
298 }
299}