vv_agent/workspace/
local.rs1use 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}