Skip to main content

vv_agent/workspace/
local.rs

1use std::any::Any;
2use std::fs;
3use std::io::{Error, ErrorKind};
4use std::path::{Path, PathBuf};
5
6use super::{
7    absolutize_path, expand_home_path, glob_match, normalize_path_lexically,
8    normalized_glob_pattern, path_to_posix, suffix_with_dot, system_time_to_utc_isoformat,
9    FileInfo, WorkspaceBackend,
10};
11
12#[derive(Debug, Clone)]
13pub struct LocalWorkspaceBackend {
14    pub root: PathBuf,
15    pub allow_outside_root: bool,
16}
17
18impl LocalWorkspaceBackend {
19    pub fn new(root: impl Into<PathBuf>) -> Self {
20        Self {
21            root: root.into(),
22            allow_outside_root: false,
23        }
24    }
25
26    fn resolve_path(&self, path: &str) -> std::io::Result<PathBuf> {
27        let root = self.normalized_root();
28        let candidate = expand_home_path(path);
29        let target = if candidate.is_absolute() {
30            candidate
31        } else {
32            root.join(&candidate)
33        };
34        let normalized = resolve_existing_or_parent(&target)?;
35        if !self.allow_outside_root && normalized != root && !normalized.starts_with(&root) {
36            return Err(Error::new(
37                ErrorKind::PermissionDenied,
38                format!("Path escapes workspace: {path}"),
39            ));
40        }
41        Ok(normalized)
42    }
43
44    fn normalized_root(&self) -> PathBuf {
45        self.root
46            .canonicalize()
47            .unwrap_or_else(|_| absolutize_path(&self.root))
48    }
49
50    fn output_path(&self, path: &Path) -> String {
51        let root = self.normalized_root();
52        if let Ok(relative) = path.strip_prefix(&root) {
53            let output = path_to_posix(relative);
54            if output.is_empty() {
55                ".".to_string()
56            } else {
57                output
58            }
59        } else {
60            path.to_string_lossy().to_string()
61        }
62    }
63}
64
65fn resolve_existing_or_parent(path: &Path) -> std::io::Result<PathBuf> {
66    if path.exists() {
67        return path.canonicalize();
68    }
69    let parent = path.parent().unwrap_or_else(|| Path::new("."));
70    let resolved_parent = if parent.exists() {
71        parent.canonicalize()?
72    } else {
73        normalize_path_lexically(parent.to_path_buf())
74    };
75    Ok(match path.file_name() {
76        Some(file_name) => resolved_parent.join(file_name),
77        None => resolved_parent,
78    })
79}
80
81impl WorkspaceBackend for LocalWorkspaceBackend {
82    fn as_any(&self) -> &dyn Any {
83        self
84    }
85
86    fn list_files(&self, base: &str, glob: &str) -> std::io::Result<Vec<String>> {
87        let root = self.resolve_path(base)?;
88        let mut files = Vec::new();
89        if root.exists() && root.is_dir() {
90            let pattern = normalized_glob_pattern(glob);
91            for entry in walk_recursive(&root)? {
92                if entry.is_file() {
93                    let Ok(relative_from_base) = entry.strip_prefix(&root) else {
94                        continue;
95                    };
96                    if !glob_match(&path_to_posix(relative_from_base), &pattern) {
97                        continue;
98                    }
99                    files.push(self.output_path(&entry));
100                }
101            }
102        }
103        files.sort();
104        Ok(files)
105    }
106
107    fn read_text(&self, path: &str) -> std::io::Result<String> {
108        let bytes = fs::read(self.resolve_path(path)?)?;
109        Ok(String::from_utf8_lossy(&bytes).to_string())
110    }
111
112    fn read_bytes(&self, path: &str) -> std::io::Result<Vec<u8>> {
113        fs::read(self.resolve_path(path)?)
114    }
115
116    fn write_text(&self, path: &str, content: &str, append: bool) -> std::io::Result<usize> {
117        let target = self.resolve_path(path)?;
118        if let Some(parent) = target.parent() {
119            fs::create_dir_all(parent)?;
120        }
121        if append {
122            use std::io::Write;
123            let mut file = fs::OpenOptions::new()
124                .create(true)
125                .append(true)
126                .open(target)?;
127            file.write_all(content.as_bytes())?;
128            Ok(content.len())
129        } else {
130            fs::write(&target, content)?;
131            Ok(content.len())
132        }
133    }
134
135    fn file_info(&self, path: &str) -> std::io::Result<Option<FileInfo>> {
136        let target = self.resolve_path(path)?;
137        if !target.exists() {
138            return Ok(None);
139        }
140        let metadata = fs::metadata(&target)?;
141        let modified_at = metadata
142            .modified()
143            .map(system_time_to_utc_isoformat)
144            .unwrap_or_else(|_| system_time_to_utc_isoformat(std::time::SystemTime::UNIX_EPOCH));
145        Ok(Some(FileInfo {
146            path: self.output_path(&target),
147            is_file: metadata.is_file(),
148            is_dir: metadata.is_dir(),
149            size: metadata.len(),
150            modified_at,
151            suffix: suffix_with_dot(&target.to_string_lossy()),
152        }))
153    }
154
155    fn exists(&self, path: &str) -> bool {
156        self.resolve_path(path)
157            .map(|path| path.exists())
158            .unwrap_or(false)
159    }
160
161    fn is_file(&self, path: &str) -> bool {
162        self.resolve_path(path)
163            .map(|path| path.is_file())
164            .unwrap_or(false)
165    }
166
167    fn mkdir(&self, path: &str) -> std::io::Result<()> {
168        fs::create_dir_all(self.resolve_path(path)?)
169    }
170}
171
172fn walk_recursive(root: &Path) -> std::io::Result<Vec<PathBuf>> {
173    let mut stack = vec![root.to_path_buf()];
174    let mut entries = Vec::new();
175    while let Some(path) = stack.pop() {
176        let reader = match fs::read_dir(&path) {
177            Ok(reader) => reader,
178            Err(error) if path != root => {
179                if error.kind() == ErrorKind::PermissionDenied {
180                    continue;
181                }
182                return Err(error);
183            }
184            Err(error) => return Err(error),
185        };
186        for entry in reader {
187            let entry = entry?;
188            let entry_path = entry.path();
189            if entry_path.is_dir() {
190                stack.push(entry_path.clone());
191            }
192            entries.push(entry_path);
193        }
194    }
195    Ok(entries)
196}